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    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
3586    .top-nav, .page, .loading { position: relative; z-index: 2; }
3587    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
3588    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
3589    .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); }
3590    .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; }
3591    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
3592    .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)); }
3593    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
3594    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
3595    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
3596    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
3597    .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; }
3598    .nav-project-pill.visible { display:inline-flex; }
3599    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
3600    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
3601    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
3602    .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; }
3603    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
3604    .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; }
3605    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
3606    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
3607    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
3608    .theme-toggle .icon-sun { display:none; }
3609    body.dark-theme .theme-toggle .icon-sun { display:block; }
3610    body.dark-theme .theme-toggle .icon-moon { display:none; }
3611    .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; }
3612    .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;}
3613    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
3614    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
3615    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
3616    .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
3617    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
3618    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
3619    .wb-stats-header { padding: 10px 24px 0; }
3620    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
3621    .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
3622    .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); }
3623    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3624    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3625    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
3626    .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; }
3627    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
3628    .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; }
3629    .ws-badge:hover .ws-lang-tooltip { display:block; }
3630    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
3631    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
3632    .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; }
3633    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
3634    .ws-divider { display: none; }
3635    .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%; }
3636    .ws-path-link:hover { color:var(--oxide); }
3637    body.dark-theme .ws-path-link { color:var(--oxide); }
3638    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
3639    .ws-stat-output .ws-value { overflow:hidden; display:block; }
3640    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
3641    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
3642    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
3643    .ws-mini-box-lg { flex:2 1 0; }
3644    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
3645    .ws-mini-box-br { flex:1.5 1 0; }
3646    .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); }
3647    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
3648    .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
3649    .path-scope-grid .input-group { width:100%; align-self:start; }
3650    .path-scope-sep { background:var(--line); margin:4px 14px; }
3651    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
3652    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
3653    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
3654    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
3655    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
3656    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
3657    .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; }
3658    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3659    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3660    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
3661    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
3662    .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; }
3663    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
3664    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
3665    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
3666    .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; }
3667    .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); }
3668    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
3669    .side-info-card { padding: 18px; }
3670    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
3671    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
3672    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
3673    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
3674    .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); }
3675    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
3676    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3677    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
3678    .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; }
3679    .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
3680    .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; }
3681    .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
3682    .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); }
3683    .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; }
3684    .step-button:hover { background: var(--surface-2); }
3685    .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); }
3686    .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; }
3687    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
3688    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
3689    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
3690    .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); }
3691    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
3692    .step-nav-sum-row:last-child { border-bottom:none; }
3693    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
3694    .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; }
3695    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
3696    .quick-scan-section { padding: 10px 4px 14px; }
3697    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
3698    .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; }
3699    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
3700    .quick-scan-btn:active { transform:translateY(0); }
3701    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
3702    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
3703    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
3704    @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);} }
3705    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
3706    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
3707    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
3708    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
3709    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
3710    .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; }
3711    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
3712    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
3713    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
3714    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
3715    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
3716    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
3717    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
3718    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
3719    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
3720    .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
3721    .card-body { padding: 22px; }
3722    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
3723    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
3724    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
3725    .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
3726    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
3727    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
3728    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
3729    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
3730    .field { min-width:0; }
3731    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
3732    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; }
3733    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); }
3734    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
3735    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); }
3736    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3737    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3738    .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; }
3739    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
3740    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
3741    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
3742    .input-group.compact { grid-template-columns: 1fr auto auto; }
3743    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
3744    .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)); }
3745    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
3746    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
3747    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
3748    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
3749    .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; }
3750    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
3751    .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; }
3752    .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); }
3753    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
3754    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
3755    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
3756    button.secondary { background: var(--surface); }
3757    .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); }
3758    .section + .wizard-actions { border-top: none; padding-top: 0; }
3759    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
3760    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3761    .field-help-grid.coupled-help { margin-top: 12px; }
3762    .field-help-grid.preset-grid { align-items: start; }
3763    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
3764    .preset-inline-row .field { margin: 0; }
3765    .preset-inline-row .explainer-card { margin: 0; }
3766    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
3767    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
3768    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
3769    .output-field-row .field { margin: 0; }
3770    .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; }
3771    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
3772    .step3-subtitle { margin-bottom: 28px; }
3773    .counting-intro { margin-bottom: 22px; max-width: none; }
3774    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
3775    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
3776    .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; }
3777    .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; }
3778    .section-spacer-top { margin-top: 28px; }
3779    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
3780    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
3781    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
3782    .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); }
3783    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
3784    .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; }
3785    .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; }
3786    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
3787    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3788    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
3789    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
3790    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
3791    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
3792    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
3793    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
3794    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
3795    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
3796    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
3797    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
3798    .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); }
3799    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
3800    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
3801    .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; }
3802    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
3803    .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; }
3804    .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; }
3805    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
3806    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
3807    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
3808    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
3809    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
3810    .advanced-rule-description strong { color: var(--text); }
3811    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
3812    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
3813    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
3814    .review-link:hover { text-decoration: underline; }
3815    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
3816    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
3817    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
3818    .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; }
3819    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
3820    .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; }
3821    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
3822    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
3823    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
3824    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3825    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
3826    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
3827    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
3828    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
3829    .review-card ul { padding-left: 18px; margin: 0; }
3830    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
3831    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
3832    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
3833    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
3834    .review-card { min-height: 200px; }
3835    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
3836    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
3837    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
3838    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
3839    .lang-overflow-chip { position:relative; cursor:default; }
3840    .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; }
3841    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
3842    .git-inline-row { align-items:start; }
3843    .mixed-line-card { display:flex; flex-direction:column; }
3844    .preset-inline-row .toggle-card { justify-content: center; }
3845        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
3846    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
3847    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
3848    .explorer-title { font-size: 18px; font-weight: 850; }
3849    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
3850    .explorer-subtitle.wide { max-width: none; }
3851    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
3852    .better-spacing { align-items:flex-start; justify-content:flex-end; }
3853    .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; }
3854    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
3855    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
3856    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
3857    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
3858    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
3859    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
3860    .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; }
3861    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
3862    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
3863    .scope-stat-button.supported { background: var(--success-bg); }
3864    .scope-stat-button.skipped { background: var(--warn-bg); }
3865    .scope-stat-button.unsupported { background: var(--danger-bg); }
3866    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
3867    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
3868    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
3869    [data-tooltip] { position: relative; }
3870    [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); }
3871    [data-tooltip]:hover::after { display: block; }
3872    .scope-stat-button[data-tooltip] { cursor: pointer; }
3873    .badge[data-tooltip] { cursor: help; }
3874    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
3875    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
3876    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
3877    .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; }
3878    .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; }
3879    code { display:inline-block; margin-top:0; padding:2px 7px; }
3880    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3881    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
3882    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
3883    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
3884    .language-pill.muted-pill { color: var(--muted); }
3885    button.language-pill { appearance:none; cursor:pointer; }
3886    .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); }
3887    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
3888    .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; }
3889    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
3890    .file-explorer-search-row { margin-left: auto; }
3891    .explorer-filter-select { min-width: 170px; width: 170px; }
3892    .explorer-search { min-width: 300px; width: 300px; }
3893    .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); }
3894    .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; }
3895    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
3896    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
3897    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
3898    .file-explorer-tree { max-height: 560px; overflow:auto; }
3899    .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); }
3900    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
3901    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
3902    .tree-row.hidden-by-filter { display:none !important; }
3903    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
3904    .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; }
3905    .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; }
3906    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
3907    .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
3908    .tree-node { display:inline-flex; align-items:center; min-width:0; }
3909    .tree-node-dir { color: var(--text); font-weight: 800; }
3910    .tree-node-supported { color: var(--success-text); }
3911    .tree-node-skipped { color: var(--warn-text); }
3912    .tree-node-unsupported { color: var(--danger-text); }
3913    .tree-node-more { color: var(--muted-2); font-style: italic; }
3914    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
3915    .tree-status-cell { display:flex; justify-content:flex-start; }
3916    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
3917    .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
3918    .loading.active { display:flex; }
3919    .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; }
3920    .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; }
3921    @keyframes spin { to { transform: rotate(360deg);} }
3922    .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
3923    .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
3924    @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
3925    .hidden { display:none !important; }
3926    .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; }
3927    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
3928    .site-footer a:hover { color: var(--text); text-decoration: underline; }
3929    @media (max-width: 1280px) { .layout { grid-template-columns: 200px 1fr; } .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
3930    @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; } }
3931    .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;}
3932    @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));}}
3933  </style>
3934</head>
3935<body>
3936  <div class="background-watermarks" aria-hidden="true">
3937    <img src="/images/logo/logo-text.png" alt="" />
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  </div>
3952  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
3953  <div class="top-nav">
3954    <div class="top-nav-inner">
3955      <a class="brand" href="/">
3956        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
3957        <div class="brand-copy">
3958          <div class="brand-title">OxideSLOC</div>
3959          <div class="brand-subtitle">Local analysis workbench</div>
3960        </div>
3961      </a>
3962      <div class="nav-project-slot">
3963        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
3964          <span class="nav-project-label">Project</span>
3965          <span class="nav-project-value" id="nav-project-title">samples/basic</span>
3966        </div>
3967      </div>
3968      <div class="nav-status">
3969        <a class="nav-pill" href="/">Home</a>
3970        <a class="nav-pill" href="/view-reports">View Reports</a>
3971        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
3972        <div class="server-status-wrap">
3973          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
3974          <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>
3975        </div>
3976        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
3977          <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>
3978          <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>
3979        </button>
3980      </div>
3981    </div>
3982  </div>
3983
3984  <div class="loading" id="loading">
3985    <div class="loading-card">
3986      <div class="spinner"></div>
3987      <h2>Scanning project...</h2>
3988      <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>
3989      <div class="progress-bar"><span></span></div>
3990    </div>
3991  </div>
3992
3993  <div class="page">
3994    <div class="workbench-strip">
3995      <div class="workbench-box wb-stats">
3996        <div class="wb-stats-header">
3997          <span class="wb-stats-title">Analysis session</span>
3998        </div>
3999        <div class="ws-left">
4000          <div class="ws-stat">
4001            <span class="ws-label">Analyzers</span>
4002            <span class="ws-value">
4003              <span class="ws-badge">41 languages
4004                <div class="ws-lang-tooltip">
4005                  <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4006                  <div class="ws-lang-grid">
4007                    <span class="ws-lang-item">Assembly</span>
4008                    <span class="ws-lang-item">C</span>
4009                    <span class="ws-lang-item">C++</span>
4010                    <span class="ws-lang-item">C#</span>
4011                    <span class="ws-lang-item">Clojure</span>
4012                    <span class="ws-lang-item">CSS</span>
4013                    <span class="ws-lang-item">Dart</span>
4014                    <span class="ws-lang-item">Dockerfile</span>
4015                    <span class="ws-lang-item">Elixir</span>
4016                    <span class="ws-lang-item">Erlang</span>
4017                    <span class="ws-lang-item">F#</span>
4018                    <span class="ws-lang-item">Go</span>
4019                    <span class="ws-lang-item">Groovy</span>
4020                    <span class="ws-lang-item">Haskell</span>
4021                    <span class="ws-lang-item">HTML</span>
4022                    <span class="ws-lang-item">Java</span>
4023                    <span class="ws-lang-item">JavaScript</span>
4024                    <span class="ws-lang-item">Julia</span>
4025                    <span class="ws-lang-item">Kotlin</span>
4026                    <span class="ws-lang-item">Lua</span>
4027                    <span class="ws-lang-item">Makefile</span>
4028                    <span class="ws-lang-item">Nim</span>
4029                    <span class="ws-lang-item">Obj-C</span>
4030                    <span class="ws-lang-item">OCaml</span>
4031                    <span class="ws-lang-item">Perl</span>
4032                    <span class="ws-lang-item">PHP</span>
4033                    <span class="ws-lang-item">PowerShell</span>
4034                    <span class="ws-lang-item">Python</span>
4035                    <span class="ws-lang-item">R</span>
4036                    <span class="ws-lang-item">Ruby</span>
4037                    <span class="ws-lang-item">Rust</span>
4038                    <span class="ws-lang-item">Scala</span>
4039                    <span class="ws-lang-item">SCSS</span>
4040                    <span class="ws-lang-item">Shell</span>
4041                    <span class="ws-lang-item">SQL</span>
4042                    <span class="ws-lang-item">Svelte</span>
4043                    <span class="ws-lang-item">Swift</span>
4044                    <span class="ws-lang-item">TypeScript</span>
4045                    <span class="ws-lang-item">Vue</span>
4046                    <span class="ws-lang-item">XML</span>
4047                    <span class="ws-lang-item">Zig</span>
4048                  </div>
4049                </div>
4050              </span>
4051            </span>
4052          </div>
4053          <div class="ws-divider"></div>
4054          <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4055          <div class="ws-divider"></div>
4056          <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4057          <div class="ws-divider"></div>
4058          <div class="ws-stat ws-stat-output">
4059            <span class="ws-label">Output</span>
4060            <span class="ws-value">
4061              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4062                <span id="ws-output-root">project/sloc</span>
4063              </button>
4064            </span>
4065          </div>
4066        </div>
4067      </div>
4068      <div class="workbench-box ws-history-group">
4069        <div class="ws-history-label">Scan history</div>
4070        <div class="ws-history-inner">
4071          <div class="ws-mini-box ws-mini-box-sm">
4072            <div class="ws-mini-label">Scans</div>
4073            <div class="ws-mini-value" id="ws-scan-count">—</div>
4074          </div>
4075          <div class="ws-mini-box ws-mini-box-lg">
4076            <div class="ws-mini-label">Last Scan</div>
4077            <div class="ws-mini-value" id="ws-last-scan">—</div>
4078          </div>
4079          <div class="ws-mini-box ws-mini-box-br">
4080            <div class="ws-mini-label">Branch</div>
4081            <div class="ws-mini-value" id="ws-branch">—</div>
4082          </div>
4083        </div>
4084      </div>
4085    </div>
4086
4087    <div class="layout">
4088      <aside class="side-stack">
4089        <section class="step-nav">
4090        <h3>Guided scan setup</h3>
4091        <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4092        <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4093        <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4094        <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4095
4096        <div class="step-nav-info" id="step-nav-info">
4097          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4098          <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>
4099        </div>
4100
4101        <div class="step-nav-summary" id="step-nav-summary" style="display:none;">
4102          <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>
4103          <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>
4104          <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>
4105        </div>
4106
4107        <div class="quick-scan-divider"></div>
4108        <div class="quick-scan-section">
4109          <div class="quick-scan-label">No customization needed?</div>
4110          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4111            <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>
4112            Quick Scan
4113          </button>
4114          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4115        </div>
4116        </section>
4117
4118      </aside>
4119
4120      <section class="card">
4121        <div class="card-header">
4122          <div class="card-title-row">
4123            <div>
4124              <h1 class="card-title">Guided scan configuration</h1>
4125              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4126            </div>
4127            <div class="wizard-progress" aria-label="Scan setup progress">
4128              <div class="wizard-progress-top">
4129                <span class="wizard-progress-label">Setup progress</span>
4130                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4131              </div>
4132              <div class="wizard-progress-track">
4133                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4134              </div>
4135            </div>
4136          </div>
4137        </div>
4138        <div class="card-body">
4139          <form method="post" action="/analyze" id="analyze-form">
4140            <div class="wizard-step active" data-step="1">
4141              <div class="section">
4142                <div class="section-kicker">Step 1</div>
4143                <h2>Select project and preview scope</h2>
4144                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4145                <div class="field" style="margin:10px 0 0;">
4146                  <label for="path">Project path</label>
4147                  <div class="path-scope-grid">
4148                    <div class="input-group">
4149                      <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4150                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4151                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4152                    </div>
4153                    <div class="path-scope-sep"></div>
4154                    <div class="scope-legend-row">
4155                      <span class="scope-legend-label">Scope legend:</span>
4156                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4157                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4158                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4159                    </div>
4160                  </div>
4161                  <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4162                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4163                </div>
4164
4165                <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4166
4167                <div id="preview-panel" style="margin-top:0;">
4168                  <div class="preview-error">Loading preview...</div>
4169                </div>
4170              </div>
4171
4172              <div class="section">
4173                <div class="field-grid">
4174                  <div class="field">
4175                    <label for="include_globs">Include globs</label>
4176                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
4177                    <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>
4178                  </div>
4179                  <div class="field">
4180                    <label for="exclude_globs">Exclude globs</label>
4181                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
4182                    <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>
4183                  </div>
4184                </div>
4185                <div class="glob-guidance-grid">
4186                  <div class="glob-guidance-card">
4187                    <strong>How to read them</strong>
4188                    <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>
4189                  </div>
4190                  <div class="glob-guidance-card">
4191                    <strong>Common include examples</strong>
4192                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4193                  </div>
4194                  <div class="glob-guidance-card">
4195                    <strong>Common exclude examples</strong>
4196                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4197                  </div>
4198                </div>
4199              </div>
4200
4201              <div class="section" style="margin-top:14px;">
4202                <div class="preset-inline-row git-inline-row">
4203                  <div class="toggle-card" style="margin:0;">
4204                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4205                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4206                    <label class="checkbox">
4207                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4208                      <div>
4209                        <span>Detect and separate git submodules</span>
4210                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4211                      </div>
4212                    </label>
4213                  </div>
4214                  <div class="explainer-card prominent" style="margin:0;">
4215                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4216                    <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>
4217                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4218    path = libs/core
4219    url  = https://github.com/org/core.git
4220
4221[submodule "libs/ui"]
4222    path = libs/ui
4223    url  = https://github.com/org/ui.git</div>
4224                  </div>
4225                </div>
4226              </div>
4227
4228              <div class="wizard-actions">
4229                <div class="left"></div>
4230                <div class="right">
4231                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4232                </div>
4233              </div>
4234            </div>
4235
4236            <div class="wizard-step" data-step="2">
4237              <div class="section">
4238                <div class="section-kicker">Step 2</div>
4239                <h2>Choose counting behavior</h2>
4240                <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>
4241                <div class="subsection-bar">Primary line classification</div>
4242                <div class="preset-inline-row" style="align-items:start;">
4243                  <div class="toggle-card mixed-line-card" style="margin:0;">
4244                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4245                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4246                    <select id="mixed_line_policy" name="mixed_line_policy">
4247                      <option value="code_only">Code only</option>
4248                      <option value="code_and_comment">Code and comment</option>
4249                      <option value="comment_only">Comment only</option>
4250                      <option value="separate_mixed_category">Separate mixed category</option>
4251                    </select>
4252                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4253                  </div>
4254                  <div class="explainer-card prominent" style="margin:0;">
4255                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4256                    <div class="explainer-body" id="mixed-policy-description"></div>
4257                    <div class="code-sample" id="mixed-policy-example"></div>
4258                  </div>
4259                </div>
4260              </div>
4261
4262              <div class="subsection-bar">Additional scan rules</div>
4263              <div class="scan-rules-grid">
4264                <div class="preset-inline-row">
4265                  <div class="toggle-card" style="margin:0;">
4266                    <div class="field-help-title">Generated files</div>
4267                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4268                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4269                  </div>
4270                  <div class="explainer-card prominent" style="margin:0;">
4271                    <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>
4272                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4273# Files matching codegen patterns are excluded:
4274#   *.generated.cs  *.pb.go  *.g.dart</div>
4275                  </div>
4276                </div>
4277                <div class="preset-inline-row">
4278                  <div class="toggle-card" style="margin:0;">
4279                    <div class="field-help-title">Minified files</div>
4280                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4281                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4282                  </div>
4283                  <div class="explainer-card prominent" style="margin:0;">
4284                    <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>
4285                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4286# Heuristic: very long lines + low whitespace ratio
4287#   jquery.min.js  bundle.min.css  → skipped</div>
4288                  </div>
4289                </div>
4290                <div class="preset-inline-row">
4291                  <div class="toggle-card" style="margin:0;">
4292                    <div class="field-help-title">Vendor directories</div>
4293                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4294                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4295                  </div>
4296                  <div class="explainer-card prominent" style="margin:0;">
4297                    <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>
4298                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4299# Directories named vendor/ node_modules/ third_party/
4300#   → entire subtree is excluded from totals</div>
4301                  </div>
4302                </div>
4303                <div class="preset-inline-row">
4304                  <div class="toggle-card" style="margin:0;">
4305                    <div class="field-help-title">Lockfiles and manifests</div>
4306                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4307                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4308                  </div>
4309                  <div class="explainer-card prominent" style="margin:0;">
4310                    <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>
4311                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
4312# Files like package-lock.json  Cargo.lock  yarn.lock
4313#   → skipped unless this is enabled</div>
4314                  </div>
4315                </div>
4316                <div class="preset-inline-row">
4317                  <div class="toggle-card" style="margin:0;">
4318                    <div class="field-help-title">Binary handling</div>
4319                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4320                    <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>
4321                  </div>
4322                  <div class="explainer-card prominent" style="margin:0;">
4323                    <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>
4324                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
4325# Detected via long lines + low whitespace heuristic
4326#   .png  .exe  .so  → skipped silently</div>
4327                  </div>
4328                </div>
4329                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4330                  <div class="toggle-card" style="margin:0;">
4331                    <div class="field-help-title">Python docstrings</div>
4332                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4333                    <label class="checkbox">
4334                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4335                      <span>Count as comment-style lines</span>
4336                    </label>
4337                  </div>
4338                  <div class="explainer-card prominent" style="margin:0;">
4339                    <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>
4340                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4341                  </div>
4342                </div>
4343              </div>
4344              <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4345                  <div class="always-tracked-tip">
4346                    <div class="always-tracked-tip-icon">ℹ</div>
4347                    <div class="always-tracked-tip-body">
4348                      <div class="field-help-title">Always tracked — not configurable</div>
4349                      <h4>Comment and blank-line basics</h4>
4350                      <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>
4351                    </div>
4352                  </div>
4353                  <div class="always-tracked-tip">
4354                    <div class="always-tracked-tip-icon">→</div>
4355                    <div class="always-tracked-tip-body">
4356                      <div class="field-help-title">What these settings change</div>
4357                      <h4>Lines on the boundary</h4>
4358                      <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>
4359                    </div>
4360                  </div>
4361                </div>
4362
4363              <div class="wizard-actions">
4364                <div class="left">
4365                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4366                </div>
4367                <div class="right">
4368                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4369                </div>
4370              </div>
4371            </div>
4372
4373            <div class="wizard-step" data-step="3">
4374              <div class="section">
4375                <div class="section-kicker">Step 3</div>
4376                <h2>Output and report identity</h2>
4377                <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>
4378                <div class="preset-inline-row" style="align-items:start;">
4379                  <div class="toggle-card" style="margin:0;">
4380                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4381                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4382                    <select id="scan_preset">
4383                      <option value="balanced">Balanced local scan</option>
4384                      <option value="code_focused">Code focused</option>
4385                      <option value="comment_audit">Comment audit</option>
4386                      <option value="deep_review">Deep review</option>
4387                    </select>
4388                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4389                  </div>
4390                  <div class="explainer-card">
4391                    <div class="field-help-title">Selected scan preset</div>
4392                    <div class="explainer-body" id="scan-preset-description"></div>
4393                    <div class="preset-summary-row" id="scan-preset-summary"></div>
4394                    <div class="code-sample" id="scan-preset-example"></div>
4395                    <div class="preset-note" id="scan-preset-note"></div>
4396                  </div>
4397                </div>
4398                <hr class="step3-separator" />
4399                <div class="preset-inline-row" style="align-items:start;">
4400                  <div class="toggle-card" style="margin:0;">
4401                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4402                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4403                    <select id="artifact_preset">
4404                      <option value="review">Review bundle</option>
4405                      <option value="full">Full bundle</option>
4406                      <option value="html_only">HTML only</option>
4407                      <option value="machine">Machine bundle</option>
4408                    </select>
4409                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4410                  </div>
4411                  <div class="explainer-card">
4412                    <div class="field-help-title">Selected artifact preset</div>
4413                    <div class="explainer-body" id="artifact-preset-description"></div>
4414                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
4415                    <div class="code-sample" id="artifact-preset-example"></div>
4416                  </div>
4417                </div>
4418              </div>
4419
4420              <div class="section section-spacer-top">
4421                <div class="output-field-row">
4422                  <div class="field">
4423                    <label for="output_dir">Output directory</label>
4424                    <div class="input-group compact">
4425                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4426                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4427                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
4428                    </div>
4429                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4430                  </div>
4431                  <div class="output-field-aside">
4432                    <strong>Where reports land</strong>
4433                    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.
4434                  </div>
4435                </div>
4436              </div>
4437
4438              <div class="section section-spacer-top">
4439                <div class="output-field-row">
4440                  <div class="field">
4441                    <label for="report_title">Report title</label>
4442                    <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4443                    <div class="hint">Appears in HTML and PDF output headers.</div>
4444                  </div>
4445                  <div class="output-field-aside">
4446                    <strong>Shown in exported artifacts</strong>
4447                    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.
4448                  </div>
4449                </div>
4450              </div>
4451
4452              <div class="section">
4453                <div class="section-kicker">Artifacts</div>
4454                <div class="artifact-grid">
4455                  <div class="artifact-card selected" data-artifact="html">
4456                    <div class="marker">✓</div>
4457                    <div class="artifact-icon">H</div>
4458                    <h4>HTML report</h4>
4459                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4460                    <div class="artifact-tags">
4461                      <span class="soft-chip">Best for visual review</span>
4462                      <span class="soft-chip">Embeddable preview</span>
4463                    </div>
4464                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4465                  </div>
4466                  <div class="artifact-card selected" data-artifact="pdf">
4467                    <div class="marker">✓</div>
4468                    <div class="artifact-icon">P</div>
4469                    <h4>PDF export</h4>
4470                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4471                    <div class="artifact-tags">
4472                      <span class="soft-chip">Portable snapshot</span>
4473                      <span class="soft-chip">Good for handoff</span>
4474                    </div>
4475                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4476                  </div>
4477                  <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4478                    <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4479                    <div class="artifact-icon">J</div>
4480                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
4481                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
4482                    <div class="artifact-tags">
4483                      <span class="soft-chip">Required for compare</span>
4484                      <span class="soft-chip">Auto-enabled</span>
4485                    </div>
4486                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
4487                  </div>
4488                </div>
4489                <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
4490              </div>
4491
4492              <div class="wizard-actions">
4493                <div class="left">
4494                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
4495                </div>
4496                <div class="right">
4497                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
4498                </div>
4499              </div>
4500            </div>
4501
4502            <div class="wizard-step" data-step="4">
4503              <div class="section">
4504                <div class="section-kicker">Step 4</div>
4505                <h2>Review selections and run</h2>
4506                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
4507                <div class="review-grid">
4508                  <div class="review-card highlight">
4509                    <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>
4510                    <ul id="review-scan-summary"></ul>
4511                  </div>
4512                  <div class="review-card highlight">
4513                    <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>
4514                    <ul id="review-count-summary"></ul>
4515                  </div>
4516                  <div class="review-card">
4517                    <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>
4518                    <ul id="review-artifact-summary"></ul>
4519                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
4520                  </div>
4521                  <div class="review-card">
4522                    <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>
4523                    <ul id="review-preview-summary"></ul>
4524                  </div>
4525                </div>
4526              </div>
4527
4528              <div class="wizard-actions">
4529                <div class="left">
4530                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
4531                </div>
4532                <div class="right">
4533                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
4534                </div>
4535              </div>
4536            </div></form>
4537        </div>
4538      </section>
4539    </div>
4540  </div>
4541
4542  <script>
4543    (function () {
4544      var form = document.getElementById("analyze-form");
4545      var loading = document.getElementById("loading");
4546      var submitButton = document.getElementById("submit-button");
4547      var pathInput = document.getElementById("path");
4548      var outputDirInput = document.getElementById("output_dir");
4549      var reportTitleInput = document.getElementById("report_title");
4550      var previewPanel = document.getElementById("preview-panel");
4551      var refreshButton = document.getElementById("refresh-preview");
4552      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
4553      var useSamplePath = document.getElementById("use-sample-path");
4554      var useDefaultOutput = document.getElementById("use-default-output");
4555      var browsePath = document.getElementById("browse-path");
4556      var browseOutputDir = document.getElementById("browse-output-dir");
4557      var themeToggle = document.getElementById("theme-toggle");
4558      var mixedLinePolicy = document.getElementById("mixed_line_policy");
4559      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
4560      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
4561      var scanPreset = document.getElementById("scan_preset");
4562      var artifactPreset = document.getElementById("artifact_preset");
4563      var includeGlobsInput = document.getElementById("include_globs");
4564      var excludeGlobsInput = document.getElementById("exclude_globs");
4565      var liveReportTitle = document.getElementById("live-report-title");
4566      var navProjectPill = document.getElementById("nav-project-pill");
4567      var navProjectTitle = document.getElementById("nav-project-title");
4568      var reportTitlePreview = null;
4569      var wizardProgressFill = document.getElementById("wizard-progress-fill");
4570      var wizardProgressValue = document.getElementById("wizard-progress-value");
4571      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
4572      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
4573      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
4574      var reportTitleTouched = false;
4575      var currentStep = 1;
4576      var previewTimer = null;
4577      var quickScanBtn = document.getElementById("quick-scan-btn");
4578
4579      if (quickScanBtn) {
4580        quickScanBtn.addEventListener("click", function () {
4581          var pathVal = pathInput ? pathInput.value.trim() : "";
4582          if (!pathVal) {
4583            alert("Please enter or browse to a project path first.");
4584            return;
4585          }
4586          quickScanBtn.disabled = true;
4587          quickScanBtn.textContent = "Scanning...";
4588          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
4589          if (loading) loading.classList.add("active");
4590          if (form) form.submit();
4591        });
4592      }
4593
4594      var mixedPolicyInfo = {
4595        code_only: {
4596          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.",
4597          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'
4598        },
4599        code_and_comment: {
4600          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.",
4601          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'
4602        },
4603        comment_only: {
4604          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.",
4605          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'
4606        },
4607        separate_mixed_category: {
4608          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.",
4609          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'
4610        }
4611      };
4612
4613      var scanPresetInfo = {
4614        balanced: {
4615          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.",
4616          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
4617          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
4618          note: "Best when you want a stable local overview before making deeper adjustments.",
4619          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4620        },
4621        code_focused: {
4622          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
4623          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
4624          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
4625          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
4626          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4627        },
4628        comment_audit: {
4629          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
4630          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
4631          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
4632          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
4633          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4634        },
4635        deep_review: {
4636          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
4637          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
4638          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
4639          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
4640          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
4641        }
4642      };
4643
4644      var artifactPresetInfo = {
4645        review: {
4646          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.",
4647          chips: ["HTML", "PDF"],
4648          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
4649        },
4650        full: {
4651          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.",
4652          chips: ["HTML", "PDF", "JSON"],
4653          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
4654        },
4655        html_only: {
4656          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.",
4657          chips: ["HTML only", "Fast local review"],
4658          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
4659        },
4660        machine: {
4661          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
4662          chips: ["HTML", "JSON"],
4663          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
4664        }
4665      };
4666
4667      function applyTheme(theme) {
4668        if (theme === "dark") document.body.classList.add("dark-theme");
4669        else document.body.classList.remove("dark-theme");
4670      }
4671
4672      function loadSavedTheme() {
4673        var saved = null;
4674        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
4675        applyTheme(saved === "dark" ? "dark" : "light");
4676      }
4677
4678      function updateScrollProgress() {
4679        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
4680        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
4681        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
4682        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
4683        var step = Math.min(Math.max(currentStep, 1), 4);
4684        var base = stepBase[step];
4685        var end  = stepEnd[step];
4686
4687        var scrollFrac = 0;
4688        var activePanel = document.querySelector(".wizard-step.active");
4689        if (activePanel) {
4690          var scrollTop = window.scrollY || window.pageYOffset || 0;
4691          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
4692          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
4693          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
4694          var scrolled = scrollTop + viewH - panelTop;
4695          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
4696        }
4697
4698        var percent = Math.round(base + (end - base) * scrollFrac);
4699        percent = Math.min(end, Math.max(base, percent));
4700        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
4701        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
4702      }
4703
4704      function updateWizardProgress() {
4705        updateScrollProgress();
4706      }
4707
4708      var stepDescriptions = [
4709        "Choose a project folder, apply scope filters, and preview which files will be counted.",
4710        "Configure how mixed code-plus-comment lines and docstrings are classified.",
4711        "Pick your output formats, scan preset, and where reports are saved.",
4712        "Review all settings and launch the analysis."
4713      ];
4714
4715      function updateStepNav(step) {
4716        var infoLabel = document.getElementById("step-nav-info-label");
4717        var infoDesc  = document.getElementById("step-nav-info-desc");
4718        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
4719        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
4720
4721        var summary = document.getElementById("step-nav-summary");
4722        if (summary) summary.style.display = step > 1 ? "" : "none";
4723
4724        var snavPath   = document.getElementById("snav-path");
4725        var snavOutput = document.getElementById("snav-output");
4726        var snavTitle  = document.getElementById("snav-title");
4727        var pv = pathInput ? pathInput.value.trim() : "";
4728        var ov = outputDirInput ? outputDirInput.value.trim() : "";
4729        var tv = reportTitleInput ? reportTitleInput.value.trim() : "";
4730        if (snavPath)   snavPath.textContent   = pv  || "—";
4731        if (snavOutput) snavOutput.textContent  = ov  || "auto";
4732        if (snavTitle)  snavTitle.textContent   = tv  || "—";
4733      }
4734
4735      function setStep(step, pushHistory) {
4736        currentStep = step;
4737        stepPanels.forEach(function (panel) {
4738          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
4739        });
4740        stepButtons.forEach(function (button) {
4741          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
4742        });
4743        updateWizardProgress();
4744        updateStepNav(step);
4745
4746        if (pushHistory !== false) {
4747          try {
4748            history.pushState({ wizardStep: step }, "", "#step" + step);
4749          } catch (e) {}
4750        }
4751
4752        var wizardTop =
4753          document.querySelector(".page-shell") ||
4754          document.querySelector(".page") ||
4755          document.querySelector(".card") ||
4756          document.body;
4757
4758        var top = 0;
4759        try {
4760          top = Math.max(0, wizardTop.getBoundingClientRect().top + window.scrollY - 16);
4761        } catch (e) {
4762          top = 0;
4763        }
4764
4765        window.scrollTo({ top: top, behavior: "smooth" });
4766      }
4767
4768      window.addEventListener("popstate", function (e) {
4769        if (e.state && e.state.wizardStep) {
4770          setStep(e.state.wizardStep, false);
4771        } else {
4772          var hashMatch = location.hash.match(/^#step([1-4])$/);
4773          if (hashMatch) setStep(Number(hashMatch[1]), false);
4774        }
4775      });
4776
4777      function inferTitleFromPath(value) {
4778        if (!value) return "project";
4779        var cleaned = value.replace(/[\/\\]+$/, "");
4780        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
4781        return parts.length ? parts[parts.length - 1] : value;
4782      }
4783
4784      function updateReportTitleFromPath() {
4785        var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
4786        if (!reportTitleTouched) {
4787          reportTitleInput.value = inferred;
4788        }
4789        var title = reportTitleInput.value || inferred;
4790        if (liveReportTitle) liveReportTitle.textContent = title;
4791        if (reportTitlePreview) reportTitlePreview.textContent = title;
4792        document.title = "OxideSLOC | " + title;
4793
4794        var projectPath = (pathInput.value || "").trim();
4795        if (navProjectPill && navProjectTitle) {
4796          if (projectPath.length > 0) {
4797            navProjectTitle.textContent = inferred;
4798            navProjectPill.classList.add("visible");
4799          } else {
4800            navProjectTitle.textContent = "";
4801            navProjectPill.classList.remove("visible");
4802          }
4803        }
4804      }
4805
4806      function updateMixedPolicyUI() {
4807        var key = mixedLinePolicy.value || "code_only";
4808        var info = mixedPolicyInfo[key];
4809        document.getElementById("mixed-policy-description").textContent = info.description;
4810        document.getElementById("mixed-policy-example").textContent = info.example;
4811      }
4812
4813      function updatePythonDocstringUI() {
4814        var checked = !!pythonDocstrings.checked;
4815        document.getElementById("python-docstring-example").textContent = checked
4816          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
4817          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
4818        document.getElementById("python-docstring-live-help").textContent = checked
4819          ? "Enabled: docstrings contribute to comment-style totals."
4820          : "Disabled: docstrings are not counted as comment content.";
4821      }
4822
4823      function renderPresetChips(targetId, chips) {
4824        var target = document.getElementById(targetId);
4825        if (!target) return;
4826        target.innerHTML = (chips || []).map(function (chip) {
4827          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
4828        }).join('');
4829      }
4830
4831      function updatePresetDescriptions() {
4832        var scanInfo = scanPresetInfo[scanPreset.value];
4833        var artifactInfo = artifactPresetInfo[artifactPreset.value];
4834        document.getElementById("scan-preset-description").textContent = scanInfo.description;
4835        document.getElementById("scan-preset-example").textContent = scanInfo.example;
4836        document.getElementById("scan-preset-note").textContent = scanInfo.note;
4837        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
4838        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
4839        renderPresetChips("scan-preset-summary", scanInfo.chips);
4840        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
4841      }
4842
4843      function applyScanPreset() {
4844        var info = scanPresetInfo[scanPreset.value];
4845        if (!info || !info.apply) return;
4846        mixedLinePolicy.value = info.apply.mixed;
4847        pythonDocstrings.checked = !!info.apply.docstrings;
4848        document.getElementById("generated_file_detection").value = info.apply.generated;
4849        document.getElementById("minified_file_detection").value = info.apply.minified;
4850        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
4851        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
4852        document.getElementById("binary_file_behavior").value = info.apply.binary;
4853        updateMixedPolicyUI();
4854        updatePythonDocstringUI();
4855      }
4856
4857      function applyArtifactPreset() {
4858        var enabled = { html: false, pdf: false, json: false };
4859        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
4860        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
4861        if (artifactPreset.value === "html_only") { enabled.html = true; }
4862        if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
4863
4864        artifactCards.forEach(function (card) {
4865          var artifact = card.getAttribute("data-artifact");
4866          var checked = !!enabled[artifact];
4867          var checkbox = card.querySelector(".artifact-checkbox");
4868          checkbox.checked = checked;
4869          card.classList.toggle("selected", checked);
4870        });
4871      }
4872
4873      function toggleArtifactCard(card) {
4874        var checkbox = card.querySelector(".artifact-checkbox");
4875        checkbox.checked = !checkbox.checked;
4876        card.classList.toggle("selected", checkbox.checked);
4877      }
4878
4879      function updateReview() {
4880        var scanSummary = document.getElementById("review-scan-summary");
4881        var countSummary = document.getElementById("review-count-summary");
4882        var artifactSummary = document.getElementById("review-artifact-summary");
4883        var outputSummary = document.getElementById("review-output-summary");
4884        var previewSummary = document.getElementById("review-preview-summary");
4885        var readinessSummary = document.getElementById("review-readiness-summary");
4886        var includeText = document.getElementById("include_globs").value.trim();
4887        var excludeText = document.getElementById("exclude_globs").value.trim();
4888        var sidePathPreview = document.getElementById("side-path-preview");
4889        var sideOutputPreview = document.getElementById("side-output-preview");
4890        var sideTitlePreview = document.getElementById("side-title-preview");
4891
4892        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
4893        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
4894        if (sideTitlePreview) {
4895          var rt = document.getElementById("report_title");
4896          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
4897        }
4898
4899        scanSummary.innerHTML = ""
4900          + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
4901          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
4902          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
4903
4904        countSummary.innerHTML = ""
4905          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
4906          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
4907          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
4908          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
4909          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
4910          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
4911          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
4912          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
4913
4914        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
4915        artifactSummary.innerHTML = ""
4916          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
4917          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
4918
4919        outputSummary.innerHTML = ""
4920          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
4921          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
4922
4923        if (previewSummary) {
4924          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
4925          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
4926          var statMap = {};
4927          statButtons.forEach(function (button) {
4928            var valueNode = button.querySelector('.scope-stat-value');
4929            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
4930          });
4931          previewSummary.innerHTML = ''
4932            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
4933            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
4934            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
4935            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
4936            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
4937            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
4938
4939          if (readinessSummary) {
4940            var selectedArtifactsCount = selectedArtifacts.length;
4941            readinessSummary.innerHTML = ''
4942              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
4943              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
4944              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
4945              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
4946          }
4947        }
4948      }
4949
4950      function escapeHtml(value) {
4951        return String(value)
4952          .replace(/&/g, "&amp;")
4953          .replace(/</g, "&lt;")
4954          .replace(/>/g, "&gt;")
4955          .replace(/"/g, "&quot;")
4956          .replace(/'/g, "&#39;");
4957      }
4958
4959      function isPythonVisible() {
4960        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
4961      }
4962
4963      function syncPythonVisibility() {
4964        var html = previewPanel.textContent || "";
4965        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
4966        pythonWraps.forEach(function (node) {
4967          node.classList.toggle("hidden", !hasPython);
4968        });
4969      }
4970
4971      function attachPreviewInteractions() {
4972        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
4973        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
4974        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
4975        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
4976        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
4977        var searchInput = previewPanel.querySelector("#explorer-search");
4978        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
4979        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
4980        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
4981        var activeFilter = "all";
4982        var activeLanguage = "";
4983        var searchTerm = "";
4984        var currentSortKey = null;
4985        var currentSortOrder = "asc";
4986        var childRows = {};
4987
4988        rows.forEach(function (row) {
4989          var parentId = row.getAttribute("data-parent-id") || "";
4990          var rowId = row.getAttribute("data-row-id") || "";
4991          if (!childRows[parentId]) childRows[parentId] = [];
4992          childRows[parentId].push(rowId);
4993        });
4994
4995        function rowById(id) {
4996          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
4997        }
4998
4999        function hasCollapsedAncestor(row) {
5000          var parentId = row.getAttribute("data-parent-id");
5001          while (parentId) {
5002            var parent = rowById(parentId);
5003            if (!parent) break;
5004            if (parent.getAttribute("data-expanded") === "false") return true;
5005            parentId = parent.getAttribute("data-parent-id");
5006          }
5007          return false;
5008        }
5009
5010        function updateToggleGlyph(row) {
5011          var toggle = row.querySelector(".tree-toggle");
5012          if (!toggle) return;
5013          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5014        }
5015
5016        function rowSortValue(row, key) {
5017          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5018        }
5019
5020        function updateSortButtons() {
5021          sortButtons.forEach(function (button) {
5022            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5023            var indicator = button.querySelector(".tree-sort-indicator");
5024            button.classList.toggle("active", isActive);
5025            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5026            if (indicator) {
5027              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5028            }
5029          });
5030        }
5031
5032        function sortSiblingRows() {
5033          if (!treeContainer) {
5034            updateSortButtons();
5035            return;
5036          }
5037
5038          var rowMap = {};
5039          var childrenMap = {};
5040          rows.forEach(function (row) {
5041            var rowId = row.getAttribute("data-row-id");
5042            var parentId = row.getAttribute("data-parent-id") || "";
5043            rowMap[rowId] = row;
5044            if (!childrenMap[parentId]) childrenMap[parentId] = [];
5045            childrenMap[parentId].push(rowId);
5046          });
5047
5048          Object.keys(childrenMap).forEach(function (parentId) {
5049            if (!parentId) return;
5050            childrenMap[parentId].sort(function (a, b) {
5051              var rowA = rowMap[a];
5052              var rowB = rowMap[b];
5053              if (!currentSortKey) {
5054                return Number(a) - Number(b);
5055              }
5056              var valueA = rowSortValue(rowA, currentSortKey);
5057              var valueB = rowSortValue(rowB, currentSortKey);
5058              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5059              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5060              var fallbackA = rowSortValue(rowA, "name");
5061              var fallbackB = rowSortValue(rowB, "name");
5062              if (fallbackA < fallbackB) return -1;
5063              if (fallbackA > fallbackB) return 1;
5064              return Number(a) - Number(b);
5065            });
5066          });
5067
5068          var orderedIds = [];
5069          function pushChildren(parentId) {
5070            (childrenMap[parentId] || []).forEach(function (childId) {
5071              orderedIds.push(childId);
5072              pushChildren(childId);
5073            });
5074          }
5075
5076          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5077            orderedIds.push(topId);
5078            pushChildren(topId);
5079          });
5080
5081          orderedIds.forEach(function (id) {
5082            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5083          });
5084          updateSortButtons();
5085        }
5086
5087        function updateLanguageButtons() {
5088          languageButtons.forEach(function (button) {
5089            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5090            var isActive = languageValue === activeLanguage;
5091            button.classList.toggle("active", isActive);
5092          });
5093        }
5094
5095        function rowSelfMatches(row) {
5096          var kind = row.getAttribute("data-kind");
5097          var status = row.getAttribute("data-status");
5098          var language = (row.getAttribute("data-language") || "").toLowerCase();
5099          var name = row.getAttribute("data-name-lower") || "";
5100          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5101          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5102          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5103          var passesLanguage = !activeLanguage || language === activeLanguage;
5104          return passesFilter && passesSearch && passesLanguage;
5105        }
5106
5107        function hasMatchingDescendant(rowId) {
5108          return (childRows[rowId] || []).some(function (childId) {
5109            var childRow = rowById(childId);
5110            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5111          });
5112        }
5113
5114        function rowMatches(row) {
5115          if (rowSelfMatches(row)) return true;
5116          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5117        }
5118
5119        function resetViewState() {
5120          activeFilter = "all";
5121          activeLanguage = "";
5122          searchTerm = "";
5123          currentSortKey = null;
5124          currentSortOrder = "asc";
5125          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5126          if (searchInput) searchInput.value = "";
5127          if (filterSelect) filterSelect.value = "all";
5128          updateLanguageButtons();
5129        }
5130
5131        function applyVisibility() {
5132          rows.forEach(function (row) {
5133            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5134            row.classList.toggle("hidden-by-filter", !visible);
5135            row.style.display = visible ? "grid" : "none";
5136          });
5137          buttons.forEach(function (button) {
5138            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5139          });
5140          if (filterSelect) filterSelect.value = activeFilter;
5141        }
5142
5143        buttons.forEach(function (button) {
5144          button.addEventListener("click", function () {
5145            var filterValue = button.getAttribute("data-filter") || "all";
5146            if (filterValue === "reset-view") {
5147              resetViewState();
5148              sortSiblingRows();
5149              applyVisibility();
5150              return;
5151            }
5152            activeFilter = filterValue;
5153            applyVisibility();
5154          });
5155        });
5156
5157        rows.forEach(function (row) {
5158          updateToggleGlyph(row);
5159          var toggle = row.querySelector(".tree-toggle");
5160          if (toggle) {
5161            toggle.addEventListener("click", function () {
5162              var expanded = row.getAttribute("data-expanded") !== "false";
5163              row.setAttribute("data-expanded", expanded ? "false" : "true");
5164              updateToggleGlyph(row);
5165              applyVisibility();
5166            });
5167          }
5168        });
5169
5170        actionButtons.forEach(function (button) {
5171          button.addEventListener("click", function () {
5172            var action = button.getAttribute("data-explorer-action");
5173            if (action === "expand-all") {
5174              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5175            } else if (action === "collapse-all") {
5176              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5177            } else if (action === "clear-filters") {
5178              resetViewState();
5179            }
5180            sortSiblingRows();
5181            applyVisibility();
5182          });
5183        });
5184
5185        if (filterSelect) {
5186          filterSelect.addEventListener("change", function () {
5187            activeFilter = filterSelect.value || "all";
5188            applyVisibility();
5189          });
5190        }
5191
5192        languageButtons.forEach(function (button) {
5193          button.addEventListener("click", function () {
5194            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5195            updateLanguageButtons();
5196            applyVisibility();
5197          });
5198        });
5199
5200        sortButtons.forEach(function (button) {
5201          button.addEventListener("click", function () {
5202            var sortKey = button.getAttribute("data-sort-key");
5203            if (currentSortKey === sortKey) {
5204              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5205            } else {
5206              currentSortKey = sortKey;
5207              currentSortOrder = "asc";
5208            }
5209            sortSiblingRows();
5210            applyVisibility();
5211          });
5212        });
5213
5214        if (searchInput) {
5215          searchInput.addEventListener("input", function () {
5216            searchTerm = searchInput.value.trim().toLowerCase();
5217            applyVisibility();
5218          });
5219        }
5220
5221        updateLanguageButtons();
5222        sortSiblingRows();
5223        applyVisibility();
5224      }
5225
5226      function loadPreview() {
5227        if (!previewPanel || !pathInput) return;
5228        var path = pathInput.value || "samples/basic";
5229        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5230        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5231        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5232        var previewUrl = "/preview?path=" + encodeURIComponent(path)
5233          + "&include_globs=" + encodeURIComponent(includeValue)
5234          + "&exclude_globs=" + encodeURIComponent(excludeValue);
5235        fetch(previewUrl)
5236          .then(function (response) { return response.text(); })
5237          .then(function (html) {
5238            previewPanel.innerHTML = html;
5239            attachPreviewInteractions();
5240            syncPythonVisibility();
5241            updateReview();
5242            setTimeout(collapseLanguagePills, 50);
5243          })
5244          .catch(function (err) {
5245            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5246          });
5247      }
5248
5249      function pickDirectory(targetInput, kind) {
5250        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5251        if (browseButton) browseButton.disabled = true;
5252
5253        if (previewPanel && targetInput === pathInput) {
5254          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5255        }
5256
5257        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
5258          .then(function (response) { return response.json(); })
5259          .then(function (data) {
5260            if (data && data.selected_path) {
5261              targetInput.value = data.selected_path;
5262
5263              if (targetInput === pathInput) {
5264                updateReportTitleFromPath();
5265                autoSetOutputDir(data.selected_path);
5266                fetchProjectHistory(data.selected_path);
5267                loadPreview();
5268              }
5269
5270              updateReview();
5271            } else if (targetInput === pathInput) {
5272              // Cancelled — keep existing value and refresh preview with current path
5273              loadPreview();
5274            }
5275          })
5276          .catch(function () {
5277            window.alert("Directory picker request failed.");
5278            if (previewPanel && targetInput === pathInput) {
5279              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5280            }
5281          })
5282          .finally(function () {
5283            if (browseButton) browseButton.disabled = false;
5284          });
5285      }
5286
5287      if (themeToggle) {
5288        themeToggle.addEventListener("click", function () {
5289          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5290          applyTheme(nextTheme);
5291          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5292        });
5293      }
5294
5295      stepButtons.forEach(function (button) {
5296        button.addEventListener("click", function () {
5297          setStep(Number(button.getAttribute("data-step-target")));
5298        });
5299      });
5300
5301      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5302        button.addEventListener("click", function () {
5303          setStep(Number(button.getAttribute("data-step-target")) || 1);
5304        });
5305      });
5306
5307      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5308        button.addEventListener("click", function () {
5309          updateReview();
5310          setStep(Number(button.getAttribute("data-next")));
5311        });
5312      });
5313
5314      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5315        button.addEventListener("click", function () {
5316          setStep(Number(button.getAttribute("data-prev")));
5317        });
5318      });
5319
5320      if (useSamplePath) {
5321        useSamplePath.addEventListener("click", function () {
5322          pathInput.value = "samples/basic";
5323          updateReportTitleFromPath();
5324          loadPreview();
5325        });
5326      }
5327
5328      if (useDefaultOutput) {
5329        useDefaultOutput.addEventListener("click", function () {
5330          delete outputDirInput.dataset.userEdited;
5331          autoSetOutputDir(pathInput ? pathInput.value : "");
5332          updateReview();
5333        });
5334      }
5335
5336      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5337      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5338
5339      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5340
5341      // ── Language pill overflow: collapse to "+N more" chip ─────────────
5342      function collapseLanguagePills() {
5343        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5344        rows.forEach(function(row) {
5345          // Remove any previous overflow chip
5346          var prev = row.querySelector('.lang-overflow-chip');
5347          if (prev) prev.remove();
5348          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5349          pills.forEach(function(p) { p.style.display = ''; });
5350          if (!pills.length) return;
5351
5352          // Measure after restoring all pills
5353          var containerRight = row.getBoundingClientRect().right;
5354          var hidden = [];
5355          for (var i = pills.length - 1; i >= 1; i--) {
5356            var rect = pills[i].getBoundingClientRect();
5357            if (rect.right > containerRight + 2) {
5358              hidden.unshift(pills[i]);
5359              pills[i].style.display = 'none';
5360            } else {
5361              break;
5362            }
5363          }
5364
5365          if (hidden.length) {
5366            var chip = document.createElement('button');
5367            chip.type = 'button';
5368            chip.className = 'language-pill lang-overflow-chip';
5369            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5370            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5371            row.appendChild(chip);
5372          }
5373        });
5374      }
5375
5376      // Run after preview loads (preview panel populates language pills)
5377      var _origLoadPreviewCb = window.__previewLoaded;
5378      document.addEventListener('previewLoaded', collapseLanguagePills);
5379      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5380      setTimeout(collapseLanguagePills, 400);
5381
5382      // ── Project history & output dir auto-set ──────────────────────────
5383      var wsOutputRoot   = document.getElementById("ws-output-root");
5384      var wsScanCount    = document.getElementById("ws-scan-count");
5385      var wsLastScan     = document.getElementById("ws-last-scan");
5386      var historyBadge   = document.getElementById("path-history-badge");
5387      var historyTimer   = null;
5388
5389      var wsOutputLink = document.getElementById("ws-output-link");
5390      function syncStripOutputRoot() {
5391        var val = outputDirInput ? outputDirInput.value : "";
5392        var display = val || "project/sloc";
5393        if (wsOutputRoot) wsOutputRoot.textContent = display;
5394        if (wsOutputLink) wsOutputLink.dataset.folder = val;
5395      }
5396
5397      function autoSetOutputDir(projectPath) {
5398        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5399        if (!projectPath || !projectPath.trim()) return;
5400        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5401        outputDirInput.value = cleaned + "/sloc";
5402        syncStripOutputRoot();
5403        updateReview();
5404      }
5405
5406      var wsBranch = document.getElementById("ws-branch");
5407
5408      function fetchProjectHistory(projectPath) {
5409        if (!projectPath || !projectPath.trim()) {
5410          if (wsScanCount) wsScanCount.textContent = "—";
5411          if (wsLastScan)  wsLastScan.textContent  = "—";
5412          if (wsBranch)    wsBranch.textContent    = "—";
5413          if (historyBadge) historyBadge.style.display = "none";
5414          return;
5415        }
5416        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5417          .then(function (r) { return r.ok ? r.json() : null; })
5418          .then(function (data) {
5419            if (!data) return;
5420            var countStr = data.scan_count > 0
5421              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5422              : "never";
5423            var tsStr = data.last_scan_timestamp
5424              ? data.last_scan_timestamp.replace(" UTC","")
5425              : "—";
5426            if (wsScanCount) wsScanCount.textContent = countStr;
5427            if (wsLastScan)  wsLastScan.textContent  = tsStr;
5428            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
5429            if (data.scan_count > 0) {
5430              if (historyBadge) {
5431                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5432                historyBadge.textContent = data.scan_count + " previous scan" +
5433                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5434                  "Last: " + (data.last_scan_timestamp || "—") +
5435                  " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5436                historyBadge.className = "path-history-badge found";
5437                historyBadge.style.display = "";
5438              }
5439            } else {
5440              if (historyBadge) historyBadge.style.display = "none";
5441            }
5442          })
5443          .catch(function () {});
5444      }
5445
5446      function onPathChange() {
5447        var val = pathInput ? pathInput.value : "";
5448        updateReportTitleFromPath();
5449        autoSetOutputDir(val);
5450        clearTimeout(historyTimer);
5451        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5452        if (previewTimer) clearTimeout(previewTimer);
5453        previewTimer = setTimeout(loadPreview, 280);
5454      }
5455
5456      if (pathInput) {
5457        pathInput.addEventListener("input", onPathChange);
5458      }
5459
5460      if (outputDirInput) {
5461        outputDirInput.addEventListener("input", function () {
5462          outputDirInput.dataset.userEdited = "1";
5463          syncStripOutputRoot();
5464          updateReview();
5465        });
5466      }
5467
5468      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5469        if (!node) return;
5470        node.addEventListener("input", function () {
5471          updateReview();
5472          if (previewTimer) clearTimeout(previewTimer);
5473          previewTimer = setTimeout(loadPreview, 280);
5474        });
5475      });
5476
5477      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5478        var node = document.getElementById(id);
5479        if (node) node.addEventListener("change", updateReview);
5480      });
5481
5482      if (reportTitleInput) {
5483        reportTitleInput.addEventListener("input", function () {
5484          reportTitleTouched = reportTitleInput.value.trim().length > 0;
5485          updateReportTitleFromPath();
5486          updateReview();
5487        });
5488      }
5489
5490      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5491      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5492      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5493      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5494
5495      artifactCards.forEach(function (card) {
5496        card.addEventListener("click", function () {
5497          toggleArtifactCard(card);
5498          updateReview();
5499        });
5500      });
5501
5502      if (form && loading && submitButton) {
5503        form.addEventListener("submit", function () {
5504          submitButton.disabled = true;
5505          submitButton.textContent = "Scanning...";
5506          loading.classList.add("active");
5507        });
5508      }
5509
5510      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
5511        btn.addEventListener('click', function () {
5512          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
5513          if (!folder) return;
5514          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5515        });
5516      });
5517
5518      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
5519      if (wsOutputLink) {
5520        wsOutputLink.addEventListener('click', function () {
5521          var folder = wsOutputLink.dataset.folder || '';
5522          if (!folder) return;
5523          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5524        });
5525      }
5526
5527      loadSavedTheme();
5528      updateMixedPolicyUI();
5529      updatePythonDocstringUI();
5530      applyScanPreset();
5531      updatePresetDescriptions();
5532      applyArtifactPreset();
5533      updateReview();
5534      updateScrollProgress(); // initialise bar to 0% (step 1)
5535      window.addEventListener("scroll", updateScrollProgress, { passive: true });
5536      onPathChange();         // seed output dir, history badge, and preview from initial path
5537      loadPreview();
5538      updateStepNav(1);
5539
5540      // Restore step from URL hash on initial load (e.g., back-forward cache)
5541      (function() {
5542        var hashMatch = location.hash.match(/^#step([1-4])$/);
5543        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
5544      })();
5545
5546      (function randomizeWatermarks() {
5547        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
5548        if (!wms.length) return;
5549        var placed = [];
5550        function tooClose(top, left) {
5551          for (var i = 0; i < placed.length; i++) {
5552            var dt = Math.abs(placed[i][0] - top);
5553            var dl = Math.abs(placed[i][1] - left);
5554            if (dt < 16 && dl < 12) return true;
5555          }
5556          return false;
5557        }
5558        function pick(leftBand) {
5559          for (var attempt = 0; attempt < 50; attempt++) {
5560            var top = Math.random() * 88 + 2;
5561            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5562            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5563          }
5564          var top = Math.random() * 88 + 2;
5565          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5566          placed.push([top, left]);
5567          return [top, left];
5568        }
5569        var half = Math.floor(wms.length / 2);
5570        wms.forEach(function (img, i) {
5571          var pos = pick(i < half);
5572          var size = Math.floor(Math.random() * 80 + 110);
5573          var rot = (Math.random() * 360).toFixed(1);
5574          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
5575          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
5576        });
5577      })();
5578
5579      (function spawnCodeParticles() {
5580        var container = document.getElementById('code-particles');
5581        if (!container) return;
5582        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'];
5583        for (var i = 0; i < 38; i++) {
5584          (function(idx) {
5585            var el = document.createElement('span');
5586            el.className = 'code-particle';
5587            el.textContent = snippets[idx % snippets.length];
5588            var left = Math.random() * 94 + 2;
5589            var top = Math.random() * 88 + 6;
5590            var dur = (Math.random() * 10 + 9).toFixed(1);
5591            var delay = (Math.random() * 18).toFixed(1);
5592            var rot = (Math.random() * 26 - 13).toFixed(1);
5593            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5594            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5595            container.appendChild(el);
5596          })(i);
5597        }
5598      })();
5599    })();
5600  </script>
5601  <script>
5602    (function () {
5603      var raw = {{ prefill_json|safe }};
5604      if (!raw || typeof raw !== 'object' || !raw.path) return;
5605      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5606      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
5607      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5608      setVal('path-input', raw.path || '');
5609      setVal('include-globs', raw.include_globs || '');
5610      setVal('exclude-globs', raw.exclude_globs || '');
5611      setVal('output-dir', raw.output_dir || '');
5612      setVal('report-title', raw.report_title || '');
5613      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
5614      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
5615      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
5616      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
5617      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
5618      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
5619      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
5620      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
5621      setChecked('generate-html', raw.generate_html !== false);
5622      setChecked('generate-pdf', !!raw.generate_pdf);
5623      // Trigger dynamic UI updates after pre-fill.
5624      setTimeout(function () {
5625        var pathEl = document.getElementById('path-input');
5626        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
5627        var policyEl = document.getElementById('mixed-line-policy');
5628        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
5629      }, 80);
5630    })();
5631  </script>
5632  <footer class="site-footer">
5633    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
5634    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5635    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5636    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5637  </footer>
5638</body>
5639</html>
5640"##,
5641    ext = "html"
5642)]
5643struct IndexTemplate {
5644    version: &'static str,
5645    prefill_json: String,
5646}
5647
5648// ── SplashTemplate ────────────────────────────────────────────────────────────
5649
5650#[derive(Template)]
5651#[template(
5652    source = r##"
5653<!doctype html>
5654<html lang="en">
5655<head>
5656  <meta charset="utf-8">
5657  <meta name="viewport" content="width=device-width, initial-scale=1">
5658  <title>OxideSLOC — Source Line Analysis Workbench</title>
5659  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5660  <style>
5661    :root {
5662      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5663      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5664      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5665      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5666      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5667    }
5668    body.dark-theme {
5669      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5670      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5671    }
5672    *{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);}
5673    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5674    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
5675    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5676    .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;}
5677    @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));}}
5678    .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);}
5679    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5680    .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));}
5681    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5682    .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;}
5683    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5684    .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;}
5685    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5686    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5687    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5688    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5689    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5690    .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;}
5691    .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;}
5692    .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
5693    .hero{text-align:center;margin-bottom:52px;}
5694    .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;}
5695    @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
5696    .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
5697      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
5698      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
5699      animation:titleShimmer 4s linear infinite;}
5700    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
5701    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;}
5702    .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
5703    @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
5704    .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
5705    @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
5706    @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
5707    .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;}
5708    .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;}
5709    @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
5710    .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5711    .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);}
5712    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
5713    .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
5714    .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);}
5715    .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);}
5716    .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);}
5717    .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
5718    .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
5719    .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;}
5720    body.dark-theme .action-card-cta{color:var(--oxide);}
5721    .action-card.view .action-card-cta{color:var(--accent-2);}
5722    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
5723    .action-card.compare .action-card-cta{color:#7c3aed;}
5724    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
5725    .action-card:hover .action-card-cta{gap:12px;}
5726    .divider{height:1px;background:var(--line);margin:40px 0;}
5727    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
5728    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
5729    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
5730    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
5731      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
5732    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5733    .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
5734    body.dark-theme .info-chip-val{color:var(--oxide);}
5735    .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
5736    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
5737      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
5738      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
5739    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
5740      border:6px solid transparent;border-top-color:var(--text);}
5741    .info-chip:hover .info-chip-tip{display:block;}
5742    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5743    .site-footer a{color:var(--muted);}
5744  </style>
5745</head>
5746<body>
5747  <div class="background-watermarks" aria-hidden="true">
5748    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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  </div>
5756  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5757  <div class="top-nav">
5758    <div class="top-nav-inner">
5759      <a class="brand" href="/">
5760        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5761        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
5762      </a>
5763      <div class="nav-right">
5764        <a class="nav-pill" href="/">Home</a>
5765        <a class="nav-pill" href="/view-reports">View Reports</a>
5766        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5767        <div class="server-status-wrap">
5768          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5769          <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>
5770        </div>
5771        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5772          <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>
5773          <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>
5774        </button>
5775      </div>
5776    </div>
5777  </div>
5778
5779  <div class="page">
5780    <div class="hero">
5781      <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
5782      <h1 class="hero-title">OxideSLOC</h1>
5783      <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
5784    </div>
5785
5786    <div class="action-grid">
5787      <a class="action-card scan" href="/scan-setup">
5788        <div class="action-card-icon">
5789          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
5790        </div>
5791        <div class="action-card-title">Scan Project</div>
5792        <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>
5793        <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>
5794      </a>
5795
5796      <a class="action-card view" href="/view-reports">
5797        <div class="action-card-icon">
5798          <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
5799        </div>
5800        <div class="action-card-title">View Reports</div>
5801        <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>
5802        <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>
5803      </a>
5804
5805      <a class="action-card compare" href="/compare-scans">
5806        <div class="action-card-icon">
5807          <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>
5808        </div>
5809        <div class="action-card-title">Compare Scans</div>
5810        <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>
5811        <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>
5812      </a>
5813    </div>
5814
5815    <div class="divider"></div>
5816
5817    <div class="info-strip">
5818      <div class="info-chip">
5819        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
5820        <div class="info-chip-val">41</div>
5821        <div class="info-chip-label">Languages</div>
5822      </div>
5823      <div class="info-chip">
5824        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
5825        <div class="info-chip-val">100%</div>
5826        <div class="info-chip-label">Self-contained</div>
5827      </div>
5828      <div class="info-chip">
5829        <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
5830        <div class="info-chip-val">HTML</div>
5831        <div class="info-chip-label">Exportable reports</div>
5832      </div>
5833      <div class="info-chip">
5834        <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
5835        <div class="info-chip-val">Git</div>
5836        <div class="info-chip-label">Submodule support</div>
5837      </div>
5838      <div class="info-chip">
5839        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
5840        <div class="info-chip-val">IEEE</div>
5841        <div class="info-chip-label">1045-1992</div>
5842      </div>
5843    </div>
5844  </div>
5845
5846  <footer class="site-footer">
5847    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
5848    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5849    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5850    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5851  </footer>
5852
5853  <script>
5854    (function () {
5855      var storageKey = 'oxide-sloc-theme';
5856      var body = document.body;
5857      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
5858      var toggle = document.getElementById('theme-toggle');
5859      if (toggle) toggle.addEventListener('click', function () {
5860        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
5861        body.classList.toggle('dark-theme', next === 'dark');
5862        try { localStorage.setItem(storageKey, next); } catch(e) {}
5863      });
5864      (function randomizeWatermarks() {
5865        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5866        if (!wms.length) return;
5867        var placed = [];
5868        function tooClose(top, left) {
5869          for (var i = 0; i < placed.length; i++) {
5870            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
5871            if (dt < 16 && dl < 12) return true;
5872          }
5873          return false;
5874        }
5875        function pick(leftBand) {
5876          for (var attempt = 0; attempt < 50; attempt++) {
5877            var top = Math.random() * 88 + 2;
5878            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5879            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5880          }
5881          var top = Math.random() * 88 + 2;
5882          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5883          placed.push([top, left]); return [top, left];
5884        }
5885        var half = Math.floor(wms.length / 2);
5886        wms.forEach(function (img, i) {
5887          var pos = pick(i < half);
5888          var size = Math.floor(Math.random() * 100 + 120);
5889          var rot = (Math.random() * 360).toFixed(1);
5890          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
5891          img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
5892        });
5893      })();
5894
5895      (function spawnCodeParticles() {
5896        var container = document.getElementById('code-particles');
5897        if (!container) return;
5898        var snippets = [
5899          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
5900          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
5901          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
5902          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
5903          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
5904        ];
5905        var count = 38;
5906        for (var i = 0; i < count; i++) {
5907          (function(idx) {
5908            var el = document.createElement('span');
5909            el.className = 'code-particle';
5910            var text = snippets[idx % snippets.length];
5911            el.textContent = text;
5912            var left = Math.random() * 94 + 2;
5913            var top = Math.random() * 88 + 6;
5914            var dur = (Math.random() * 10 + 9).toFixed(1);
5915            var delay = (Math.random() * 18).toFixed(1);
5916            var rot = (Math.random() * 26 - 13).toFixed(1);
5917            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5918            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
5919              + '--rot:' + rot + 'deg;--op:' + op + ';'
5920              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5921            container.appendChild(el);
5922          })(i);
5923        }
5924      })();
5925    })();
5926  </script>
5927</body>
5928</html>
5929"##,
5930    ext = "html"
5931)]
5932struct SplashTemplate {}
5933
5934// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
5935
5936#[derive(Template)]
5937#[template(
5938    source = r##"
5939<!doctype html>
5940<html lang="en">
5941<head>
5942  <meta charset="utf-8">
5943  <meta name="viewport" content="width=device-width, initial-scale=1">
5944  <title>OxideSLOC — Start a Scan</title>
5945  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5946  <style>
5947    :root {
5948      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5949      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5950      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5951      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5952      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5953    }
5954    body.dark-theme {
5955      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5956      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5957    }
5958    *{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);}
5959    .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);}
5960    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5961    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
5962    .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));}
5963    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5964    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
5965    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
5966    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5967    .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;}
5968    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5969    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5970    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5971    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5972    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5973    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
5974    .page-header{text-align:center;margin-bottom:32px;}
5975    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
5976    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
5977    .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
5978    .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
5979    .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
5980    /* Cards */
5981    .option-grid{display:flex;flex-direction:column;gap:16px;}
5982    .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;}
5983    .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
5984    /* Two-column layout inside each card */
5985    .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
5986    .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
5987    .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
5988    .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
5989    .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
5990    .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
5991    .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
5992    .card-text{min-width:0;}
5993    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
5994    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
5995    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
5996    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
5997    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
5998    /* Right CTA column */
5999    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6000    .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;}
6001    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6002    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6003    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6004    body.dark-theme .btn-secondary{color:var(--oxide);}
6005    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6006    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6007    /* File input overlay — must be full-width so it aligns with other card-right buttons */
6008    .file-input-wrap{position:relative;width:100%;}
6009    .file-input-wrap .btn{width:100%;}
6010    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6011    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6012    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6013    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6014    .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;}
6015    @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));}}
6016    /* Recent list (card 3 — full-width section below header) */
6017    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6018    .recent-list{display:flex;flex-direction:column;gap:8px;}
6019    .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;}
6020    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6021    .recent-item-info{flex:1;min-width:0;}
6022    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6023    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6024    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6025    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6026    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6027    .site-footer a{color:var(--muted);}
6028    @media(max-width:680px){
6029      .card-body{grid-template-columns:1fr;}
6030      .card-right{flex-direction:row;flex-wrap:wrap;}
6031      .btn{flex:1;}
6032    }
6033  </style>
6034</head>
6035<body>
6036  <div class="background-watermarks" aria-hidden="true">
6037    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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  </div>
6045  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6046  <div class="top-nav">
6047    <div class="top-nav-inner">
6048      <a class="brand" href="/">
6049        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6050        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6051      </a>
6052      <div class="nav-right">
6053        <a class="nav-pill" href="/">Home</a>
6054        <a class="nav-pill" href="/view-reports">View Reports</a>
6055        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6056        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6057          <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>
6058          <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>
6059        </button>
6060      </div>
6061    </div>
6062  </div>
6063
6064  <div class="page">
6065    <div class="breadcrumb">
6066      <a href="/">Home</a>
6067      <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6068      <span>Scan Setup</span>
6069    </div>
6070
6071    <div class="page-header">
6072      <h1>How would you like to scan?</h1>
6073      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6074    </div>
6075
6076    <div class="option-grid">
6077
6078      <!-- Option 1: New scan -->
6079      <div class="option-card">
6080        <div class="card-body">
6081          <div class="card-left">
6082            <div class="option-icon new-scan">
6083              <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6084            </div>
6085            <div class="card-text">
6086              <div class="option-title">Start a new scan</div>
6087              <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>
6088              <ul class="feature-list">
6089                <li>Live project scope preview before you run</li>
6090                <li>4 line-counting modes with interactive examples</li>
6091                <li>HTML, PDF, and JSON output — your choice</li>
6092                <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6093              </ul>
6094            </div>
6095          </div>
6096          <div class="card-right">
6097            <a class="btn btn-primary" href="/scan">
6098              Configure &amp; scan
6099              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6100            </a>
6101            <p class="card-tip">Full 4-step setup · all options</p>
6102          </div>
6103        </div>
6104      </div>
6105
6106      <!-- Option 2: Load from config file -->
6107      <div class="option-card">
6108        <div class="card-body">
6109          <div class="card-left">
6110            <div class="option-icon load-config">
6111              <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>
6112            </div>
6113            <div class="card-text">
6114              <div class="option-title">Load a saved config</div>
6115              <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>
6116              <ul class="feature-list">
6117                <li>All 15 settings restored from the file</li>
6118                <li>Fully editable — change path or output dir</li>
6119                <li>Works with any scan-config.json</li>
6120              </ul>
6121            </div>
6122          </div>
6123          <div class="card-right">
6124            <div class="file-input-wrap">
6125              <button class="btn btn-secondary" id="load-config-btn" type="button">
6126                <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>
6127                Choose config file
6128              </button>
6129              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6130            </div>
6131            <p class="card-tip" id="config-file-name">Exported after every scan</p>
6132          </div>
6133        </div>
6134      </div>
6135
6136      <!-- Option 3: Re-scan recent project -->
6137      <div class="option-card" id="recent-card">
6138        <div class="card-body">
6139          <div class="card-left" style="grid-column:1/-1;">
6140            <div class="option-icon rescan">
6141              <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>
6142            </div>
6143            <div class="card-text">
6144              <div class="option-title">Re-scan a recent project</div>
6145              <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>
6146              <ul class="feature-list">
6147                <li>All 15+ settings restored from the saved config</li>
6148                <li>Path and output dir are editable before running</li>
6149                <li>Only scans with a saved config appear here</li>
6150              </ul>
6151            </div>
6152          </div>
6153        </div>
6154        <div class="section-divider"></div>
6155        <div class="recent-list" id="recent-list">
6156          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6157        </div>
6158      </div>
6159
6160    </div>
6161  </div>
6162
6163  <footer class="site-footer">
6164    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
6165    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6166    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6167    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6168  </footer>
6169
6170  <script>
6171    (function () {
6172      var storageKey = 'oxide-sloc-theme';
6173      var body = document.body;
6174      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6175      var toggle = document.getElementById('theme-toggle');
6176      if (toggle) toggle.addEventListener('click', function () {
6177        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6178        body.classList.toggle('dark-theme', next === 'dark');
6179        try { localStorage.setItem(storageKey, next); } catch(e) {}
6180      });
6181
6182      (function randomizeWatermarks() {
6183        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6184        if (!wms.length) return;
6185        var placed = [];
6186        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; }
6187        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]; }
6188        var half = Math.floor(wms.length / 2);
6189        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 + ';'; });
6190      })();
6191      (function spawnCodeParticles() {
6192        var container = document.getElementById('code-particles');
6193        if (!container) return;
6194        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'];
6195        var count = 38;
6196        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); }
6197      })();
6198
6199      // Recent scans data injected from server
6200      var recentScans = {{ recent_scans_json|safe }};
6201
6202      function configToParams(cfg) {
6203        var p = new URLSearchParams();
6204        p.set('prefilled', '1');
6205        if (cfg.path) p.set('path', cfg.path);
6206        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6207        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6208        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6209        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6210        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6211        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6212        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6213        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6214        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6215        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6216        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6217        if (cfg.report_title) p.set('report_title', cfg.report_title);
6218        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6219        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6220        return p;
6221      }
6222
6223      // Build recent scan list (capped at 3 visible entries)
6224      var list = document.getElementById('recent-list');
6225      var noNote = document.getElementById('no-recent-note');
6226      var hasAny = false;
6227      var MAX_RECENT = 3;
6228      if (Array.isArray(recentScans)) {
6229        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6230        var shown = 0;
6231        validEntries.forEach(function (entry) {
6232          if (shown >= MAX_RECENT) return;
6233          shown++;
6234          hasAny = true;
6235          var item = document.createElement('div');
6236          item.className = 'recent-item';
6237          item.title = 'Restore all settings and open wizard';
6238          item.innerHTML =
6239            '<div class="recent-item-info">' +
6240              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6241              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
6242            '</div>' +
6243            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6244          item.addEventListener('click', function () {
6245            var params = configToParams(entry.config);
6246            window.location.href = '/scan?' + params.toString();
6247          });
6248          list.appendChild(item);
6249        });
6250        if (validEntries.length > MAX_RECENT) {
6251          var moreEl = document.createElement('div');
6252          moreEl.className = 'recent-more-link';
6253          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
6254          list.appendChild(moreEl);
6255        }
6256      }
6257      if (hasAny && noNote) noNote.style.display = 'none';
6258
6259      // Config file loader
6260      var fileInput = document.getElementById('config-file-input');
6261      var fileName = document.getElementById('config-file-name');
6262      if (fileInput) {
6263        fileInput.addEventListener('change', function () {
6264          var file = fileInput.files && fileInput.files[0];
6265          if (!file) return;
6266          if (fileName) fileName.textContent = '✓ ' + file.name;
6267          var reader = new FileReader();
6268          reader.onload = function (e) {
6269            try {
6270              var cfg = JSON.parse(e.target.result);
6271              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6272              var params = configToParams(cfg);
6273              window.location.href = '/scan?' + params.toString();
6274            } catch (err) {
6275              alert('Could not parse config file: ' + err.message);
6276            }
6277          };
6278          reader.readAsText(file);
6279        });
6280      }
6281
6282      function escHtml(s) {
6283        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
6284      }
6285    })();
6286  </script>
6287</body>
6288</html>
6289"##,
6290    ext = "html"
6291)]
6292struct ScanSetupTemplate {
6293    recent_scans_json: String,
6294}
6295
6296#[derive(Template)]
6297#[template(
6298    source = r##"
6299<!doctype html>
6300<html lang="en">
6301<head>
6302  <meta charset="utf-8">
6303  <meta name="viewport" content="width=device-width, initial-scale=1">
6304  <title>OxideSLOC | {{ report_title }} | Report</title>
6305  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6306  <style>
6307    :root {
6308      --radius: 18px;
6309      --bg: #f5efe8;
6310      --surface: rgba(255,255,255,0.82);
6311      --surface-2: #fbf7f2;
6312      --surface-3: #efe6dc;
6313      --line: #e6d0bf;
6314      --line-strong: #dcb89f;
6315      --text: #43342d;
6316      --muted: #7b675b;
6317      --muted-2: #a08777;
6318      --nav: #b85d33;
6319      --nav-2: #7a371b;
6320      --accent: #6f9bff;
6321      --accent-2: #4a78ee;
6322      --oxide: #d37a4c;
6323      --oxide-2: #b35428;
6324      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6325      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6326      --success-bg: #e8f5ed;
6327      --success-text: #1a8f47;
6328      --info-bg: #eef3ff;
6329      --info-text: #4467d8;
6330    }
6331
6332    body.dark-theme {
6333      --bg: #1b1511;
6334      --surface: #261c17;
6335      --surface-2: #2d221d;
6336      --surface-3: #372922;
6337      --line: #524238;
6338      --line-strong: #6c5649;
6339      --text: #f5ece6;
6340      --muted: #c7b7aa;
6341      --muted-2: #aa9485;
6342      --nav: #b85d33;
6343      --nav-2: #7a371b;
6344      --accent: #6f9bff;
6345      --accent-2: #4a78ee;
6346      --oxide: #d37a4c;
6347      --oxide-2: #b35428;
6348      --shadow: 0 18px 42px rgba(0,0,0,0.28);
6349      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6350      --success-bg: #163927;
6351      --success-text: #8fe2a8;
6352      --info-bg: #1c2847;
6353      --info-text: #a9c1ff;
6354    }
6355
6356    * { box-sizing: border-box; }
6357    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); }
6358    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6359    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6360    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6361    .top-nav, .page { position: relative; z-index: 2; }
6362    .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); }
6363    .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; }
6364    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6365    .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)); }
6366    .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; }
6367    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6368    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6369    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6370    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6371    .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; }
6372    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6373    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6374    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6375    .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); }
6376    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6377    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6378    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6379    .theme-toggle .icon-sun { display:none; }
6380    body.dark-theme .theme-toggle .icon-sun { display:block; }
6381    body.dark-theme .theme-toggle .icon-moon { display:none; }
6382    .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; }
6383    .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;}
6384    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6385    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6386    .hero, .panel { padding: 22px; }
6387    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6388    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6389    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6390    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6391    .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; }
6392    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6393    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6394    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6395    .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6396    .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6397    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; }
6398    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6399    .delta-card-val { font-size:16px; font-weight:800; }
6400    .delta-card-val.pos { color:#1e7e34; }
6401    .delta-card-val.neg { color:#b91c1c; }
6402    .delta-card-val.mod { color:#b35428; }
6403    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6404    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6405    .compare-ts { font-size:13px; color:var(--muted); }
6406    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6407    .compare-arrow { color: var(--muted); }
6408    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
6409    .action-card { padding: 16px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6410    .action-card h3 { margin:0 0 10px; font-size: 16px; }
6411    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; }
6412    .button, .copy-button {
6413      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;
6414    }
6415    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6416    .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6417    .path-item { padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: space-between; }
6418    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6419    .path-item strong { display: block; margin-bottom: 6px; }
6420    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6421    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6422    .path-subitem { flex: 1; }
6423    .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); }
6424    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); }
6425    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6426    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6427    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6428    th:first-child, td:first-child { width: 28%; }
6429    th { color: var(--muted); font-weight: 700; }
6430    tr:last-child td { border-bottom: none; }
6431    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6432    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6433    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6434    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6435    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6436    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6437    .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; }
6438    .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6439    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6440    .muted { color: var(--muted); }
6441    .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; }
6442    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6443    .site-footer a:hover { color: var(--text); text-decoration: underline; }
6444    .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; }
6445    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6446    .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; }
6447    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6448    /* Submodule panel */
6449    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6450    /* Metrics tables stack */
6451    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6452    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6453    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6454    .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)); }
6455    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6456    /* Metrics table */
6457    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6458    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6459    .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; }
6460    .metrics-table thead th:not(:first-child) { text-align: right; }
6461    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6462    .metrics-table tbody tr:last-child td { border-bottom: none; }
6463    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6464    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6465    .metrics-table tbody tr:hover td { background: var(--surface-2); }
6466    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6467    .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; }
6468    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6469    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6470    .mt-val-pos { color: #1e7e34; font-weight: 700; }
6471    .mt-val-neg { color: #b91c1c; font-weight: 700; }
6472    .mt-val-zero { color: var(--muted); }
6473    .mt-val-mod { color: var(--oxide-2); }
6474    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6475    @media (max-width: 1180px) {
6476      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6477      .nav-project-slot, .nav-status { justify-content:flex-start; }
6478      .hero-top { flex-direction: column; }
6479    }
6480    .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;}
6481    @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));}}
6482  </style>
6483</head>
6484<body>
6485  <div class="background-watermarks" aria-hidden="true">
6486    <img src="/images/logo/logo-text.png" alt="" />
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  </div>
6501  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6502  <div class="top-nav">
6503    <div class="top-nav-inner">
6504      <a class="brand" href="/">
6505        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
6506        <div class="brand-copy">
6507          <div class="brand-title">OxideSLOC</div>
6508          <div class="brand-subtitle">Local analysis workbench</div>
6509        </div>
6510      </a>
6511      <div class="nav-project-slot">
6512        <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
6513      </div>
6514      <div class="nav-status">
6515        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
6516        <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
6517        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
6518        <div class="server-status-wrap">
6519          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6520          <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>
6521        </div>
6522        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
6523          <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>
6524          <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>
6525        </button>
6526      </div>
6527    </div>
6528  </div>
6529
6530  <div class="page">
6531    <section class="hero">
6532      <div class="hero-top">
6533        <div>
6534          <div class="soft-chip success">Run finished successfully</div>
6535          <h1 class="hero-title">{{ report_title }}</h1>
6536          <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>
6537        </div>
6538        <div class="hero-quick-actions">
6539          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
6540          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
6541          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
6542        </div>
6543      </div>
6544
6545      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
6546      <div class="compare-banner">
6547        <div class="compare-banner-body">
6548          <div class="compare-banner-meta">
6549            <span class="compare-label">Previous scan</span>
6550            <span class="compare-ts">{{ prev_ts }}</span>
6551            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
6552            {% if let Some(prev_code) = prev_run_code_lines %}
6553            <div class="compare-banner-stats" style="margin-top:4px;">
6554              <span>Code before: <strong>{{ prev_code }}</strong></span>
6555              <span class="compare-arrow">→</span>
6556              <span>Code now: <strong>{{ code_lines }}</strong></span>
6557              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
6558              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
6559            </div>
6560            {% endif %}
6561          </div>
6562          {% if delta_lines_added.is_some() %}
6563          <div class="delta-cards-inline">
6564            <div class="delta-card-inline">
6565              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
6566              <div class="delta-card-lbl">lines added</div>
6567            </div>
6568            <div class="delta-card-inline">
6569              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
6570              <div class="delta-card-lbl">lines removed</div>
6571            </div>
6572            <div class="delta-card-inline">
6573              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
6574              <div class="delta-card-lbl">unmodified lines</div>
6575            </div>
6576            <div class="delta-card-inline">
6577              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
6578              <div class="delta-card-lbl">files modified</div>
6579            </div>
6580            <div class="delta-card-inline">
6581              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
6582              <div class="delta-card-lbl">files added</div>
6583            </div>
6584            <div class="delta-card-inline">
6585              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
6586              <div class="delta-card-lbl">files removed</div>
6587            </div>
6588            <div class="delta-card-inline">
6589              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
6590              <div class="delta-card-lbl">files unchanged</div>
6591            </div>
6592          </div>
6593          {% else %}
6594          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
6595            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
6596          </p>
6597          {% endif %}
6598          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
6599        </div>
6600      </div>
6601      {% endif %}{% endif %}
6602
6603      <div class="action-grid">
6604        <div class="action-card">
6605          <h3>HTML report</h3>
6606          <div class="action-buttons">
6607            {% match html_url %}
6608              {% when Some with (url) %}
6609                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
6610              {% when None %}{% endmatch %}
6611            {% match html_download_url %}
6612              {% when Some with (url) %}
6613                <a class="button secondary" href="{{ url }}">Download HTML</a>
6614              {% when None %}{% endmatch %}
6615            {% match html_path %}
6616              {% when Some with (_path) %}{% when None %}{% endmatch %}
6617          </div>
6618        </div>
6619        <div class="action-card">
6620          <h3>PDF report</h3>
6621          <div class="action-buttons">
6622            {% match pdf_url %}
6623              {% when Some with (url) %}
6624                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
6625              {% when None %}{% endmatch %}
6626            {% match pdf_download_url %}
6627              {% when Some with (url) %}
6628                <a class="button secondary" href="{{ url }}">Download PDF</a>
6629              {% when None %}{% endmatch %}
6630            {% match pdf_path %}
6631              {% when Some with (_path) %}{% when None %}{% endmatch %}
6632          </div>
6633        </div>
6634        <div class="action-card">
6635          <h3>JSON result</h3>
6636          <div class="action-buttons">
6637            {% match json_url %}
6638              {% when Some with (url) %}
6639                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
6640              {% when None %}{% endmatch %}
6641            {% match json_download_url %}
6642              {% when Some with (url) %}
6643                <a class="button secondary" href="{{ url }}">Download JSON</a>
6644              {% when None %}{% endmatch %}
6645            {% match json_path %}
6646              {% when Some with (_path) %}{% when None %}
6647                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
6648              {% endmatch %}
6649          </div>
6650        </div>
6651        <div class="action-card">
6652          <h3>Scan config</h3>
6653          <div class="action-buttons">
6654            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
6655            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
6656          </div>
6657          <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
6658        </div>
6659      </div>
6660      {% if !submodule_rows.is_empty() %}
6661      <div class="submodule-panel">
6662        <div class="toolbar-row">
6663          <div>
6664            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
6665            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
6666          </div>
6667          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
6668        </div>
6669        <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
6670        <table style="width:100%;border-collapse:collapse;font-size:14px;">
6671          <thead>
6672            <tr>
6673              <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>
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;">Path</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:right;">Files</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;">Physical</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;">Code</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;">Comments</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;">Blank</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:center;">Report</th>
6681            </tr>
6682          </thead>
6683          <tbody>
6684            {% for row in submodule_rows %}
6685            <tr>
6686              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
6687              <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
6688              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
6689              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
6690              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
6691              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
6692              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
6693              <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>
6694            </tr>
6695            {% endfor %}
6696          </tbody>
6697        </table>
6698        </div>
6699      </div>
6700      {% endif %}
6701
6702      <div class="metrics-tables-stack">
6703
6704        <div class="metrics-table-wrap">
6705          <div class="metrics-table-title">Files</div>
6706          <table class="metrics-table">
6707            <thead>
6708              <tr>
6709                <th>Metric</th>
6710                <th>This Run</th>
6711                <th>Previous</th>
6712                <th>Change</th>
6713              </tr>
6714            </thead>
6715            <tbody>
6716              <tr>
6717                <td>Files analyzed</td>
6718                <td class="mt-val-large">{{ files_analyzed }}</td>
6719                <td>{{ prev_fa_str }}</td>
6720                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
6721              </tr>
6722              <tr>
6723                <td>Files skipped</td>
6724                <td>{{ files_skipped }}</td>
6725                <td>{{ prev_fs_str }}</td>
6726                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
6727              </tr>
6728              <tr>
6729                <td>Files modified</td>
6730                <td class="mt-val-na">—</td>
6731                <td class="mt-val-na">—</td>
6732                <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>
6733              </tr>
6734              <tr>
6735                <td>Files unchanged</td>
6736                <td class="mt-val-na">—</td>
6737                <td class="mt-val-na">—</td>
6738                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
6739              </tr>
6740            </tbody>
6741          </table>
6742        </div>
6743
6744        <div class="metrics-table-wrap">
6745          <div class="metrics-table-title">Line Counts</div>
6746          <table class="metrics-table">
6747            <thead>
6748              <tr>
6749                <th>Metric</th>
6750                <th>This Run</th>
6751                <th>Previous</th>
6752                <th>Change</th>
6753              </tr>
6754            </thead>
6755            <tbody>
6756              <tr>
6757                <td>Physical lines</td>
6758                <td class="mt-val-large">{{ physical_lines }}</td>
6759                <td>{{ prev_pl_str }}</td>
6760                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
6761              </tr>
6762              <tr>
6763                <td>Code lines</td>
6764                <td class="mt-val-large">{{ code_lines }}</td>
6765                <td>{{ prev_cl_str }}</td>
6766                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
6767              </tr>
6768              <tr>
6769                <td>Comment lines</td>
6770                <td>{{ comment_lines }}</td>
6771                <td>{{ prev_cml_str }}</td>
6772                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
6773              </tr>
6774              <tr>
6775                <td>Blank lines</td>
6776                <td>{{ blank_lines }}</td>
6777                <td>{{ prev_bl_str }}</td>
6778                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
6779              </tr>
6780              <tr>
6781                <td>Mixed (separate)</td>
6782                <td>{{ mixed_lines }}</td>
6783                <td class="mt-val-na">—</td>
6784                <td class="mt-val-na">—</td>
6785              </tr>
6786            </tbody>
6787          </table>
6788        </div>
6789
6790        <div class="metrics-tables-lower">
6791          <div class="metrics-table-wrap">
6792            <div class="metrics-table-title">Code Structure</div>
6793            <table class="metrics-table">
6794              <thead>
6795                <tr>
6796                  <th>Metric</th>
6797                  <th>This Run</th>
6798                </tr>
6799              </thead>
6800              <tbody>
6801                <tr>
6802                  <td>Functions</td>
6803                  <td>{{ functions }}</td>
6804                </tr>
6805                <tr>
6806                  <td>Classes / Types</td>
6807                  <td>{{ classes }}</td>
6808                </tr>
6809                <tr>
6810                  <td>Variables</td>
6811                  <td>{{ variables }}</td>
6812                </tr>
6813                <tr>
6814                  <td>Imports</td>
6815                  <td>{{ imports }}</td>
6816                </tr>
6817              </tbody>
6818            </table>
6819          </div>
6820
6821          <div class="metrics-table-wrap">
6822            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
6823            <table class="metrics-table">
6824              <thead>
6825                <tr>
6826                  <th>Metric</th>
6827                  <th>Change</th>
6828                </tr>
6829              </thead>
6830              <tbody>
6831                <tr>
6832                  <td>Lines added</td>
6833                  <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>
6834                </tr>
6835                <tr>
6836                  <td>Lines removed</td>
6837                  <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>
6838                </tr>
6839                <tr>
6840                  <td>Lines modified (net)</td>
6841                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
6842                </tr>
6843                <tr>
6844                  <td>Lines unmodified</td>
6845                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6846                </tr>
6847              </tbody>
6848            </table>
6849          </div>
6850        </div>
6851
6852      </div>
6853
6854      <div class="path-list">
6855        <div class="path-item">
6856          <div class="path-item-label">Project path</div>
6857          <code>{{ project_path }}</code>
6858        </div>
6859        <div class="path-item">
6860          <div class="path-item-label">Git branch</div>
6861          {% if let Some(branch) = git_branch %}
6862          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
6863          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
6864          {% else %}
6865          <code style="color:var(--muted)">—</code>
6866          {% endif %}
6867        </div>
6868        <div class="path-item path-item-split">
6869          <div class="path-subitem">
6870            <div class="path-item-label">Output folder</div>
6871            <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
6872          </div>
6873          <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
6874            <div class="path-item-label">Run ID</div>
6875            <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
6876              <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
6877              <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
6878            </div>
6879          </div>
6880        </div>
6881      </div>
6882    </section>
6883
6884    <section class="panel" style="margin-bottom: 18px;">
6885        <div class="toolbar-row">
6886          <div>
6887            <h2>Language breakdown</h2>
6888            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
6889          </div>
6890        </div>
6891        <table>
6892          <thead>
6893            <tr>
6894              <th>Language</th>
6895              <th>Files</th>
6896              <th>Physical</th>
6897              <th>Code</th>
6898              <th>Comments</th>
6899              <th>Blank</th>
6900              <th>Mixed</th>
6901              <th>Functions</th>
6902              <th>Classes</th>
6903              <th>Variables</th>
6904              <th>Imports</th>
6905            </tr>
6906          </thead>
6907          <tbody>
6908            {% for row in language_rows %}
6909            <tr>
6910              <td>{{ row.language }}</td>
6911              <td>{{ row.files }}</td>
6912              <td>{{ row.physical }}</td>
6913              <td>{{ row.code }}</td>
6914              <td>{{ row.comments }}</td>
6915              <td>{{ row.blank }}</td>
6916              <td>{{ row.mixed }}</td>
6917              <td>{{ row.functions }}</td>
6918              <td>{{ row.classes }}</td>
6919              <td>{{ row.variables }}</td>
6920              <td>{{ row.imports }}</td>
6921            </tr>
6922            {% endfor %}
6923          </tbody>
6924        </table>
6925    </section>
6926
6927  </div>
6928
6929  <script>
6930    (function () {
6931      var body = document.body;
6932      var themeToggle = document.getElementById('theme-toggle');
6933      var storageKey = 'oxide-sloc-theme';
6934
6935      function applyTheme(theme) {
6936        body.classList.toggle('dark-theme', theme === 'dark');
6937      }
6938
6939      function loadSavedTheme() {
6940        try {
6941          var saved = localStorage.getItem(storageKey);
6942          if (saved === 'dark' || saved === 'light') {
6943            applyTheme(saved);
6944          }
6945        } catch (e) {}
6946      }
6947
6948      if (themeToggle) {
6949        themeToggle.addEventListener('click', function () {
6950          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
6951          applyTheme(nextTheme);
6952          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
6953        });
6954      }
6955
6956      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
6957        button.addEventListener('click', function () {
6958          var value = button.getAttribute('data-copy-value') || '';
6959          if (!value) return;
6960          if (navigator.clipboard && navigator.clipboard.writeText) {
6961            navigator.clipboard.writeText(value).catch(function () {});
6962          }
6963        });
6964      });
6965
6966      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6967        btn.addEventListener('click', function () {
6968          var folder = btn.getAttribute('data-folder') || '';
6969          if (!folder) return;
6970          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6971        });
6972      });
6973
6974      loadSavedTheme();
6975
6976      (function randomizeWatermarks() {
6977        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6978        if (!wms.length) return;
6979        var placed = [];
6980        function tooClose(top, left) {
6981          for (var i = 0; i < placed.length; i++) {
6982            var dt = Math.abs(placed[i][0] - top);
6983            var dl = Math.abs(placed[i][1] - left);
6984            if (dt < 20 && dl < 18) return true;
6985          }
6986          return false;
6987        }
6988        function pick(leftBand) {
6989          for (var attempt = 0; attempt < 50; attempt++) {
6990            var top = Math.random() * 85 + 5;
6991            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6992            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6993          }
6994          var top = Math.random() * 85 + 5;
6995          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6996          placed.push([top, left]);
6997          return [top, left];
6998        }
6999        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7000        var half = Math.floor(wms.length / 2);
7001        wms.forEach(function (img, i) {
7002          var pos = pick(i < half);
7003          var size = Math.floor(Math.random() * 100 + 160);
7004          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7005          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7006          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7007        });
7008      })();
7009
7010      (function spawnCodeParticles() {
7011        var container = document.getElementById('code-particles');
7012        if (!container) return;
7013        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'];
7014        for (var i = 0; i < 38; i++) {
7015          (function(idx) {
7016            var el = document.createElement('span');
7017            el.className = 'code-particle';
7018            el.textContent = snippets[idx % snippets.length];
7019            var left = Math.random() * 94 + 2;
7020            var top = Math.random() * 88 + 6;
7021            var dur = (Math.random() * 10 + 9).toFixed(1);
7022            var delay = (Math.random() * 18).toFixed(1);
7023            var rot = (Math.random() * 26 - 13).toFixed(1);
7024            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7025            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7026            container.appendChild(el);
7027          })(i);
7028        }
7029      })();
7030    })();
7031  </script>
7032  <footer class="site-footer">
7033    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
7034    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7035    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7036    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7037  </footer>
7038</body>
7039</html>
7040"##,
7041    ext = "html"
7042)]
7043struct ResultTemplate {
7044    report_title: String,
7045    project_path: String,
7046    output_dir: String,
7047    run_id: String,
7048    files_analyzed: u64,
7049    files_skipped: u64,
7050    physical_lines: u64,
7051    code_lines: u64,
7052    comment_lines: u64,
7053    blank_lines: u64,
7054    mixed_lines: u64,
7055    functions: u64,
7056    classes: u64,
7057    variables: u64,
7058    imports: u64,
7059    html_url: Option<String>,
7060    pdf_url: Option<String>,
7061    json_url: Option<String>,
7062    html_download_url: Option<String>,
7063    pdf_download_url: Option<String>,
7064    json_download_url: Option<String>,
7065    html_path: Option<String>,
7066    pdf_path: Option<String>,
7067    json_path: Option<String>,
7068    language_rows: Vec<LanguageSummaryRow>,
7069    prev_run_id: Option<String>,
7070    prev_run_timestamp: Option<String>,
7071    prev_run_code_lines: Option<u64>,
7072    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
7073    prev_fa_str: String,
7074    prev_fs_str: String,
7075    prev_pl_str: String,
7076    prev_cl_str: String,
7077    prev_cml_str: String,
7078    prev_bl_str: String,
7079    // Signed change column for main metrics
7080    delta_fa_str: String,
7081    delta_fa_class: String,
7082    delta_fs_str: String,
7083    delta_fs_class: String,
7084    delta_pl_str: String,
7085    delta_pl_class: String,
7086    delta_cl_str: String,
7087    delta_cl_class: String,
7088    delta_cml_str: String,
7089    delta_cml_class: String,
7090    delta_bl_str: String,
7091    delta_bl_class: String,
7092    // delta vs previous scan
7093    delta_lines_added: Option<i64>,
7094    delta_lines_removed: Option<i64>,
7095    delta_lines_net_str: String,
7096    delta_lines_net_class: String,
7097    delta_files_added: Option<usize>,
7098    delta_files_removed: Option<usize>,
7099    delta_files_modified: Option<usize>,
7100    delta_files_unchanged: Option<usize>,
7101    delta_unmodified_lines: Option<u64>,
7102    // git context
7103    git_branch: Option<String>,
7104    git_commit: Option<String>,
7105    git_author: Option<String>,
7106    // history
7107    prev_scan_count: usize,
7108    current_scan_number: usize,
7109    // submodule breakdown (empty when not requested)
7110    submodule_rows: Vec<SubmoduleRow>,
7111    scan_config_url: String,
7112}
7113
7114#[derive(Template)]
7115#[template(
7116    source = r##"
7117<!doctype html>
7118<html lang="en">
7119<head>
7120  <meta charset="utf-8">
7121  <meta name="viewport" content="width=device-width, initial-scale=1">
7122  <title>OxideSLOC | Error</title>
7123  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7124  <style>
7125    :root {
7126      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7127      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7128      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7129      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7130    }
7131    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7132    *{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);}
7133    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7134    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7135    .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);}
7136    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7137    .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));}
7138    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7139    .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;}
7140    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7141    .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;}
7142    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7143    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7144    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7145    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7146    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7147    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7148    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7149    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7150    .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;}
7151    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7152    .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);}
7153    .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;}
7154    .btn-secondary:hover{background:var(--line);}
7155    .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;}
7156    .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;}
7157    .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;}
7158    @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));}}
7159  </style>
7160</head>
7161<body>
7162  <div class="background-watermarks" aria-hidden="true">
7163    <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7164    <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7165    <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7166    <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7167    <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7168    <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7169  </div>
7170  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7171  <div class="top-nav">
7172    <div class="top-nav-inner">
7173      <a class="brand" href="/">
7174        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7175        <div class="brand-copy">
7176          <div class="brand-title">OxideSLOC</div>
7177          <div class="brand-subtitle">Local analysis workbench</div>
7178        </div>
7179      </a>
7180      <div class="nav-right">
7181        <a class="nav-pill" href="/">Home</a>
7182        <a class="nav-pill" href="/view-reports">View Reports</a>
7183        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7184        <div class="server-status-wrap">
7185          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7186          <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>
7187        </div>
7188        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7189          <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>
7190          <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>
7191        </button>
7192      </div>
7193    </div>
7194  </div>
7195
7196  <div class="page">
7197    <div class="panel">
7198      <h1>Analysis failed</h1>
7199      <div class="error-box">{{ message }}</div>
7200      <div class="actions">
7201        <a class="btn-primary" href="/scan">Back to setup</a>
7202        {% if let Some(report_url) = last_report_url %}
7203        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7204        {% endif %}
7205        <a class="btn-secondary" href="/view-reports">View Reports</a>
7206      </div>
7207    </div>
7208  </div>
7209  <script>
7210    (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");});})();
7211    (function spawnCodeParticles() {
7212      var container = document.getElementById('code-particles');
7213      if (!container) return;
7214      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'];
7215      for (var i = 0; i < 38; i++) {
7216        (function(idx) {
7217          var el = document.createElement('span');
7218          el.className = 'code-particle';
7219          el.textContent = snippets[idx % snippets.length];
7220          var left = Math.random() * 94 + 2;
7221          var top = Math.random() * 88 + 6;
7222          var dur = (Math.random() * 10 + 9).toFixed(1);
7223          var delay = (Math.random() * 18).toFixed(1);
7224          var rot = (Math.random() * 26 - 13).toFixed(1);
7225          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7226          el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7227          container.appendChild(el);
7228        })(i);
7229      }
7230    })();
7231  </script>
7232</body>
7233</html>
7234"##,
7235    ext = "html"
7236)]
7237struct ErrorTemplate {
7238    message: String,
7239    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
7240    last_report_url: Option<String>,
7241    /// Label for the secondary action button; defaults to "View last report" when None.
7242    last_report_label: Option<String>,
7243}
7244
7245// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
7246
7247#[derive(Template)]
7248#[template(
7249    source = r##"
7250<!doctype html>
7251<html lang="en">
7252<head>
7253  <meta charset="utf-8">
7254  <meta name="viewport" content="width=device-width, initial-scale=1">
7255  <title>OxideSLOC | View Reports</title>
7256  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7257  <style>
7258    :root {
7259      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7260      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7261      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7262      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7263      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7264    }
7265    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7266    *{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);}
7267    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7268    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7269    .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);}
7270    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7271    .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));}
7272    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7273    .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;}
7274    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7275    .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;}
7276    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7277    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7278    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7279    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7280    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7281    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7282    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7283    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7284    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7285    .panel-meta{font-size:13px;color:var(--muted);}
7286    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7287    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7288    .per-page-label{font-size:13px;color:var(--muted);}
7289    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;}
7290    .filter-input{min-width:180px;cursor:text;}
7291    .table-wrap{width:100%;overflow-x:auto;}
7292    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7293    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;}
7294    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7295    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7296    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7297    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7298    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7299    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7300    tr:last-child td{border-bottom:none;}
7301    tr:hover td{background:var(--surface-2);}
7302    .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);}
7303    .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);}
7304    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7305    .metric-num{font-weight:700;color:var(--text);}
7306    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7307    .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;}
7308    .btn:hover{background:var(--line);}
7309    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7310    .btn.primary:hover{opacity:.9;}
7311    .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;}
7312    .btn-back:hover{background:var(--line);}
7313    .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;}
7314    .export-btn:hover{background:var(--line);}
7315    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7316    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7317    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7318    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7319    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7320    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7321    .pagination-info{font-size:13px;color:var(--muted);}
7322    .pagination-btns{display:flex;gap:6px;}
7323    .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;}
7324    .pg-btn:hover:not(:disabled){background:var(--line);}
7325    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7326    .pg-btn:disabled{opacity:.35;cursor:default;}
7327    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7328    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7329    .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;}
7330    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7331    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7332    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7333    .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);}
7334    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7335    .stat-chip:hover .stat-chip-tip{opacity:1;}
7336    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7337    .site-footer a{color:var(--muted);}
7338    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7339    .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%;}
7340    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7341    .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;}
7342    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7343    .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;}
7344    .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;}
7345    .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;}
7346    @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));}}
7347  </style>
7348</head>
7349<body>
7350  <div class="background-watermarks" aria-hidden="true">
7351    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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  </div>
7358  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7359  <div class="top-nav">
7360    <div class="top-nav-inner">
7361      <a class="brand" href="/">
7362        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7363        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7364      </a>
7365      <div class="nav-right">
7366        <a class="nav-pill" href="/">Home</a>
7367        <a class="nav-pill" href="/view-reports">View Reports</a>
7368        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7369        <div class="server-status-wrap">
7370          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7371          <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>
7372        </div>
7373        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7374          <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>
7375          <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>
7376        </button>
7377      </div>
7378    </div>
7379  </div>
7380
7381  <div class="page">
7382    {% if linked %}
7383    <div class="toast-success">
7384      <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>
7385      Report linked successfully — it now appears in the list below.
7386    </div>
7387    {% endif %}
7388    {% if total_scans > 0 %}
7389    <div class="summary-strip">
7390      <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>
7391      <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>
7392      <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>
7393      <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>
7394    </div>
7395    {% endif %}
7396
7397    <section class="panel">
7398      <div class="panel-header">
7399        <div>
7400          <h1>View Reports</h1>
7401          <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7402        </div>
7403        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7404          <div class="export-group">
7405            <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7406              <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>
7407              Export CSV
7408            </button>
7409            <button type="button" class="export-btn" onclick="exportHistoryXls()">
7410              <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>
7411              Export Excel
7412            </button>
7413          </div>
7414          <a class="btn-back" href="/">
7415            <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>
7416            Home
7417          </a>
7418        </div>
7419      </div>
7420
7421      <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7422        <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7423        {% if !entries.is_empty() %}
7424        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7425          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7426          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7427          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
7428        </div>
7429        {% endif %}
7430      </div>
7431      <div style="margin-bottom:14px;">
7432        <button type="button" class="btn" onclick="browseReport()">
7433          <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>
7434          Browse for Report…
7435        </button>
7436      </div>
7437
7438      {% if entries.is_empty() %}
7439      <div class="empty-state">
7440        <strong>No reports with viewable HTML yet</strong>
7441        Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7442      </div>
7443      {% else %}
7444      <div class="table-wrap">
7445        <table id="history-table">
7446          <colgroup>
7447            <col style="width:155px">
7448            <col style="width:160px">
7449            <col style="width:115px">
7450            <col style="width:88px">
7451            <col style="width:88px">
7452            <col style="width:88px">
7453            <col style="width:72px">
7454            <col style="width:80px">
7455            <col style="width:76px">
7456            <col style="width:80px">
7457            <col style="width:72px">
7458            <col style="width:92px">
7459            <col style="width:92px">
7460            <col style="width:160px">
7461          </colgroup>
7462          <thead>
7463            <tr id="history-thead">
7464              <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>
7465              <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>
7466              <th>Run ID<div class="col-resize-handle"></div></th>
7467              <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>
7468              <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>
7469              <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>
7470              <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>
7471              <th>Functions<div class="col-resize-handle"></div></th>
7472              <th>Classes<div class="col-resize-handle"></div></th>
7473              <th>Variables<div class="col-resize-handle"></div></th>
7474              <th>Imports<div class="col-resize-handle"></div></th>
7475              <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>
7476              <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>
7477              <th>Report<div class="col-resize-handle"></div></th>
7478            </tr>
7479          </thead>
7480          <tbody id="history-tbody">
7481            {% for entry in entries %}
7482            <tr class="history-row" data-run="{{ entry.run_id }}"
7483                data-timestamp="{{ entry.timestamp }}"
7484                data-project="{{ entry.project_label }}"
7485                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7486                data-skipped="{{ entry.files_skipped }}"
7487                data-comments="{{ entry.comment_lines }}"
7488                data-blank="{{ entry.blank_lines }}"
7489                data-branch="{{ entry.git_branch }}"
7490                data-commit="{{ entry.git_commit }}"
7491                style="cursor:pointer;"
7492                onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7493              <td>{{ entry.timestamp }}</td>
7494              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7495              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7496              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
7497              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7498              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7499              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
7500              <td><span class="metric-num">{{ entry.functions }}</span></td>
7501              <td><span class="metric-num">{{ entry.classes }}</span></td>
7502              <td><span class="metric-num">{{ entry.variables }}</span></td>
7503              <td><span class="metric-num">{{ entry.imports }}</span></td>
7504              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
7505              <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>
7506              <td style="overflow:visible;white-space:normal;">
7507                <div class="actions-cell">
7508                  <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
7509                  {% 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 %}
7510                </div>
7511              </td>
7512            </tr>
7513            {% endfor %}
7514          </tbody>
7515        </table>
7516      </div>
7517      <div class="pagination">
7518        <span class="pagination-info" id="pagination-info"></span>
7519        <div class="pagination-btns" id="pagination-btns"></div>
7520        <div style="display:flex;align-items:center;gap:8px;">
7521          <span class="per-page-label">Show</span>
7522          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
7523            <option value="10">10 per page</option>
7524            <option value="25" selected>25 per page</option>
7525            <option value="50">50 per page</option>
7526            <option value="100">100 per page</option>
7527          </select>
7528          <span class="per-page-label" id="page-range-label"></span>
7529        </div>
7530      </div>
7531      {% endif %}
7532    </section>
7533  </div>
7534
7535  <footer class="site-footer">
7536    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
7537    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7538    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7539    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7540  </footer>
7541
7542  <script>
7543    (function () {
7544      // ── Theme ──────────────────────────────────────────────────────────────
7545      var storageKey = 'oxide-sloc-theme';
7546      var body = document.body;
7547      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7548      var toggle = document.getElementById('theme-toggle');
7549      if (toggle) toggle.addEventListener('click', function () {
7550        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7551        body.classList.toggle('dark-theme', next === 'dark');
7552        try { localStorage.setItem(storageKey, next); } catch(e) {}
7553      });
7554
7555      // ── State ─────────────────────────────────────────────────────────────
7556      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
7557      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
7558      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
7559
7560      // Aggregate stats from first (most recent) row
7561      if (allRows.length) {
7562        var first = allRows[0];
7563        var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
7564        var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
7565        var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
7566      }
7567
7568      // ── Branch filter population ──────────────────────────────────────────
7569      (function() {
7570        var branches = {};
7571        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
7572        var sel = document.getElementById('branch-filter');
7573        if (sel) Object.keys(branches).sort().forEach(function(b) {
7574          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
7575        });
7576      })();
7577
7578      // ── Filter ────────────────────────────────────────────────────────────
7579      function getFilteredRows() {
7580        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
7581        var branch = ((document.getElementById('branch-filter') || {}).value || '');
7582        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
7583          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
7584          if (branch && (r.dataset.branch || '') !== branch) return false;
7585          return true;
7586        });
7587      }
7588
7589      // ── Pagination ────────────────────────────────────────────────────────
7590      function renderPage() {
7591        var filtered = getFilteredRows();
7592        var total = filtered.length;
7593        var totalPages = Math.max(1, Math.ceil(total / perPage));
7594        currentPage = Math.min(currentPage, totalPages);
7595        var start = (currentPage - 1) * perPage;
7596        var end = Math.min(start + perPage, total);
7597        var shown = {};
7598        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
7599        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
7600          r.style.display = shown[r.dataset.run] ? '' : 'none';
7601        });
7602        var rl = document.getElementById('page-range-label');
7603        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
7604        var info = document.getElementById('pagination-info');
7605        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
7606        var btns = document.getElementById('pagination-btns');
7607        if (!btns) return;
7608        btns.innerHTML = '';
7609        function makeBtn(lbl, pg, active, disabled) {
7610          var b = document.createElement('button');
7611          b.className = 'pg-btn' + (active ? ' active' : '');
7612          b.textContent = lbl; b.disabled = disabled;
7613          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
7614          return b;
7615        }
7616        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
7617        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
7618        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
7619        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
7620      }
7621
7622      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
7623      window.applyFilters = function() { currentPage = 1; renderPage(); };
7624
7625      // ── Sorting ───────────────────────────────────────────────────────────
7626      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
7627      function doSort(col, type, order) {
7628        var tbody = document.getElementById('history-tbody');
7629        if (!tbody) return;
7630        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7631        rows.sort(function(a, b) {
7632          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
7633          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
7634          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
7635          return va < vb ? 1 : va > vb ? -1 : 0;
7636        });
7637        rows.forEach(function(r) { tbody.appendChild(r); });
7638        currentPage = 1; renderPage();
7639      }
7640      sortHeaders.forEach(function(th) {
7641        th.addEventListener('click', function(e) {
7642          if (e.target.classList.contains('col-resize-handle')) return;
7643          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
7644          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
7645          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7646          th.classList.add('sort-' + sortOrder);
7647          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
7648          doSort(col, type, sortOrder);
7649        });
7650      });
7651
7652      // ── Column resize ─────────────────────────────────────────────────────
7653      (function() {
7654        var table = document.getElementById('history-table');
7655        if (!table) return;
7656        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7657        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
7658        ths.forEach(function(th, i) {
7659          var handle = th.querySelector('.col-resize-handle');
7660          if (!handle || !cols[i]) return;
7661          var startX, startW;
7662          handle.addEventListener('mousedown', function(e) {
7663            e.stopPropagation(); e.preventDefault();
7664            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7665            handle.classList.add('dragging');
7666            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7667            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7668            document.addEventListener('mousemove', onMove);
7669            document.addEventListener('mouseup', onUp);
7670          });
7671        });
7672      })();
7673
7674      // ── Reset view ────────────────────────────────────────────────────────
7675      window.resetView = function() {
7676        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
7677        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
7678        sortCol = null; sortOrder = 'asc';
7679        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7680        var tbody = document.getElementById('history-tbody');
7681        if (tbody) {
7682          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7683          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
7684          rows.forEach(function(r) { tbody.appendChild(r); });
7685        }
7686        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
7687        var table = document.getElementById('history-table');
7688        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7689        currentPage = 1; renderPage();
7690      };
7691
7692      renderPage();
7693
7694      (function randomizeWatermarks() {
7695        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7696        if (!wms.length) return;
7697        var placed = [];
7698        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;}
7699        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];}
7700        var half=Math.floor(wms.length/2);
7701        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+';';});
7702      })();
7703
7704      (function spawnCodeParticles() {
7705        var container = document.getElementById('code-particles');
7706        if (!container) return;
7707        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'];
7708        for (var i = 0; i < 38; i++) {
7709          (function(idx) {
7710            var el = document.createElement('span');
7711            el.className = 'code-particle';
7712            el.textContent = snippets[idx % snippets.length];
7713            var left = Math.random() * 94 + 2;
7714            var top = Math.random() * 88 + 6;
7715            var dur = (Math.random() * 10 + 9).toFixed(1);
7716            var delay = (Math.random() * 18).toFixed(1);
7717            var rot = (Math.random() * 26 - 13).toFixed(1);
7718            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7719            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7720            container.appendChild(el);
7721          })(i);
7722        }
7723      })();
7724    })();
7725
7726    function rowClick(runId, hasHtml) {
7727      if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
7728    }
7729
7730    function browseReport() {
7731      fetch('/pick-file?kind=report')
7732        .then(function(r) { return r.json(); })
7733        .then(function(data) {
7734          if (!data.cancelled && data.selected_path) {
7735            var form = document.createElement('form');
7736            form.method = 'POST';
7737            form.action = '/locate-report';
7738            var input = document.createElement('input');
7739            input.type = 'hidden';
7740            input.name = 'file_path';
7741            input.value = data.selected_path;
7742            form.appendChild(input);
7743            document.body.appendChild(form);
7744            form.submit();
7745          }
7746        })
7747        .catch(function(e) { alert('Could not open file picker: ' + e); });
7748    }
7749
7750    // ── Export helpers ────────────────────────────────────────────────────────
7751    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
7752    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7753    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);}
7754    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;');}
7755    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');}
7756
7757    var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
7758    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;}
7759    window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
7760    window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
7761  </script>
7762</body>
7763</html>
7764"##,
7765    ext = "html"
7766)]
7767struct HistoryTemplate {
7768    entries: Vec<HistoryEntryRow>,
7769    total_scans: usize,
7770    linked: bool,
7771}
7772
7773// ── CompareSelectTemplate ──────────────────────────────────────────────────────
7774
7775#[derive(Template)]
7776#[template(
7777    source = r##"
7778<!doctype html>
7779<html lang="en">
7780<head>
7781  <meta charset="utf-8">
7782  <meta name="viewport" content="width=device-width, initial-scale=1">
7783  <title>OxideSLOC | Compare Scans</title>
7784  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7785  <style>
7786    :root {
7787      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7788      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7789      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7790      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7791      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
7792    }
7793    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7794    *{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);}
7795    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7796    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7797    .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);}
7798    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7799    .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));}
7800    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7801    .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;}
7802    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7803    .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;}
7804    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7805    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7806    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7807    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7808    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7809    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7810    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7811    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7812    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7813    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
7814    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
7815    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7816    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7817    .per-page-label{font-size:13px;color:var(--muted);}
7818    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;}
7819    .filter-input{min-width:180px;cursor:text;}
7820    .table-wrap{width:100%;overflow-x:auto;}
7821    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7822    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;}
7823    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
7824    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7825    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
7826    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7827    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
7828    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7829    tr:last-child td{border-bottom:none;}
7830    tr.selected td{background:var(--sel-bg);}
7831    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
7832    tr:hover:not(.selected) td{background:var(--surface-2);}
7833    tr{cursor:pointer;}
7834    .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);}
7835    .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);}
7836    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7837    .metric-num{font-weight:700;}
7838    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7839    .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;}
7840    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
7841    .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;}
7842    .btn:hover{background:var(--line);}
7843    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7844    .btn.primary:hover{opacity:.9;}
7845    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
7846    .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;}
7847    .btn-back:hover{background:var(--line);}
7848    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7849    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7850    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7851    .pagination-info{font-size:13px;color:var(--muted);}
7852    .pagination-btns{display:flex;gap:6px;}
7853    .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;}
7854    .pg-btn:hover:not(:disabled){background:var(--line);}
7855    .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7856    .pg-btn:disabled{opacity:.35;cursor:default;}
7857    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7858    .site-footer a{color:var(--muted);}
7859    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7860    .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;}
7861    .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;}
7862    .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;}
7863    @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));}}
7864    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7865    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7866    .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;}
7867    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7868    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7869    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7870    .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);}
7871    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7872    .stat-chip:hover .stat-chip-tip{opacity:1;}
7873    .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;}
7874    .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%;}
7875    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
7876  </style>
7877</head>
7878<body>
7879  <div class="background-watermarks" aria-hidden="true">
7880    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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  </div>
7887  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7888  <div class="top-nav">
7889    <div class="top-nav-inner">
7890      <a class="brand" href="/">
7891        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7892        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
7893      </a>
7894      <div class="nav-right">
7895        <a class="nav-pill" href="/">Home</a>
7896        <a class="nav-pill" href="/view-reports">View Reports</a>
7897        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7898        <div class="server-status-wrap">
7899          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7900          <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>
7901        </div>
7902        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7903          <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>
7904          <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>
7905        </button>
7906      </div>
7907    </div>
7908  </div>
7909
7910  <div class="page">
7911    {% if total_scans > 0 %}
7912    <div class="summary-strip">
7913      <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>
7914      <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>
7915      <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>
7916      <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>
7917    </div>
7918    {% endif %}
7919    <section class="panel">
7920      <div class="panel-header">
7921        <div>
7922          <h1>Compare Scans</h1>
7923          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
7924        </div>
7925        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7926          <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
7927            <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>
7928            Compare <span class="sel-count" id="sel-count">0/2</span>
7929          </button>
7930          <a class="btn-back" href="/">
7931            <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>
7932            Home
7933          </a>
7934        </div>
7935      </div>
7936
7937      {% if entries.is_empty() %}
7938      <div class="empty-state">
7939        <strong>No scans yet</strong>
7940        Run your first analysis from the <a href="/scan">scan page</a>.
7941      </div>
7942      {% else %}
7943      <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
7944        <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
7945          <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>
7946          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
7947        </div>
7948        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7949          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7950          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7951          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
7952        </div>
7953      </div>
7954      <div class="table-wrap">
7955        <table id="compare-table">
7956          <colgroup>
7957            <col style="width:44px">
7958            <col style="width:165px">
7959            <col style="width:180px">
7960            <col style="width:110px">
7961            <col style="width:100px">
7962            <col style="width:80px">
7963            <col style="width:100px">
7964            <col style="width:90px">
7965            <col style="width:100px">
7966          </colgroup>
7967          <thead>
7968            <tr id="compare-thead">
7969              <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
7970              <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>
7971              <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>
7972              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
7973              <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>
7974              <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>
7975              <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>
7976              <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>
7977              <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>
7978            </tr>
7979          </thead>
7980          <tbody id="compare-tbody">
7981            {% for entry in entries %}
7982            <tr class="compare-row" data-run="{{ entry.run_id }}"
7983                data-timestamp="{{ entry.timestamp }}"
7984                data-project="{{ entry.project_label }}"
7985                data-files="{{ entry.files_analyzed }}"
7986                data-code="{{ entry.code_lines }}"
7987                data-comments="{{ entry.comment_lines }}"
7988                data-branch="{{ entry.git_branch }}"
7989                data-commit="{{ entry.git_commit }}"
7990                onclick="toggleRow(this, '{{ entry.run_id }}')">
7991              <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
7992              <td>{{ entry.timestamp }}</td>
7993              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7994              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
7995              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
7996              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7997              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7998              <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>
7999              <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>
8000            </tr>
8001            {% endfor %}
8002          </tbody>
8003        </table>
8004      </div>
8005      <div class="pagination">
8006        <span class="pagination-info" id="pagination-info"></span>
8007        <div class="pagination-btns" id="pagination-btns"></div>
8008        <div style="display:flex;align-items:center;gap:8px;">
8009          <span class="per-page-label">Show</span>
8010          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8011            <option value="10">10 per page</option>
8012            <option value="25" selected>25 per page</option>
8013            <option value="50">50 per page</option>
8014            <option value="100">100 per page</option>
8015          </select>
8016          <span class="per-page-label" id="page-range-label"></span>
8017        </div>
8018      </div>
8019      {% endif %}
8020    </section>
8021  </div>
8022
8023  <footer class="site-footer">
8024    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8025    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8026    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8027    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8028  </footer>
8029
8030  <script>
8031    (function () {
8032      // ── Theme ──────────────────────────────────────────────────────────────
8033      var storageKey = 'oxide-sloc-theme';
8034      var body = document.body;
8035      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8036      var toggle = document.getElementById('theme-toggle');
8037      if (toggle) toggle.addEventListener('click', function () {
8038        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8039        body.classList.toggle('dark-theme', next === 'dark');
8040        try { localStorage.setItem(storageKey, next); } catch(e) {}
8041      });
8042
8043      // ── State ─────────────────────────────────────────────────────────────
8044      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8045      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8046      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8047
8048      // ── Stat chips ────────────────────────────────────────────────────────
8049      (function() {
8050        var projects = {}, latestTs = '', latestRow = null;
8051        allRows.forEach(function(r) {
8052          var p = r.dataset.project || ''; if (p) projects[p] = true;
8053          var ts = r.dataset.timestamp || '';
8054          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8055        });
8056        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8057        if (latestRow) {
8058          var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8059          var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8060        }
8061      })();
8062
8063      // ── Branch filter population ──────────────────────────────────────────
8064      (function() {
8065        var branches = {};
8066        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8067        var sel = document.getElementById('branch-filter');
8068        if (sel) Object.keys(branches).sort().forEach(function(b) {
8069          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8070        });
8071      })();
8072
8073      // ── Filter ────────────────────────────────────────────────────────────
8074      function getFilteredRows() {
8075        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8076        var branch = ((document.getElementById('branch-filter') || {}).value || '');
8077        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8078          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8079          if (branch && (r.dataset.branch || '') !== branch) return false;
8080          return true;
8081        });
8082      }
8083
8084      // ── Pagination ────────────────────────────────────────────────────────
8085      function renderPage() {
8086        var filtered = getFilteredRows();
8087        var total = filtered.length;
8088        var totalPages = Math.max(1, Math.ceil(total / perPage));
8089        currentPage = Math.min(currentPage, totalPages);
8090        var start = (currentPage - 1) * perPage;
8091        var end = Math.min(start + perPage, total);
8092        var shown = {};
8093        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8094        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8095          r.style.display = shown[r.dataset.run] ? '' : 'none';
8096        });
8097        var rl = document.getElementById('page-range-label');
8098        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8099        var info = document.getElementById('pagination-info');
8100        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8101        var btns = document.getElementById('pagination-btns');
8102        if (!btns) return;
8103        btns.innerHTML = '';
8104        function makeBtn(lbl, pg, active, disabled) {
8105          var b = document.createElement('button');
8106          b.className = 'pg-btn' + (active ? ' active' : '');
8107          b.textContent = lbl; b.disabled = disabled;
8108          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8109          return b;
8110        }
8111        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8112        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8113        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8114        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8115      }
8116
8117      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8118      window.applyFilters = function() { currentPage = 1; renderPage(); };
8119
8120      // ── Sorting ───────────────────────────────────────────────────────────
8121      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8122      function doSort(col, type, order) {
8123        var tbody = document.getElementById('compare-tbody');
8124        if (!tbody) return;
8125        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8126        rows.sort(function(a, b) {
8127          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8128          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8129          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8130          return va < vb ? 1 : va > vb ? -1 : 0;
8131        });
8132        rows.forEach(function(r) { tbody.appendChild(r); });
8133        currentPage = 1; renderPage();
8134      }
8135      sortHeaders.forEach(function(th) {
8136        th.addEventListener('click', function(e) {
8137          if (e.target.classList.contains('col-resize-handle')) return;
8138          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8139          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8140          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8141          th.classList.add('sort-' + sortOrder);
8142          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8143          doSort(col, type, sortOrder);
8144        });
8145      });
8146
8147      // ── Column resize ─────────────────────────────────────────────────────
8148      (function() {
8149        var table = document.getElementById('compare-table');
8150        if (!table) return;
8151        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8152        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8153        ths.forEach(function(th, i) {
8154          var handle = th.querySelector('.col-resize-handle');
8155          if (!handle || !cols[i]) return;
8156          var startX, startW;
8157          handle.addEventListener('mousedown', function(e) {
8158            e.stopPropagation(); e.preventDefault();
8159            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8160            handle.classList.add('dragging');
8161            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8162            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8163            document.addEventListener('mousemove', onMove);
8164            document.addEventListener('mouseup', onUp);
8165          });
8166        });
8167      })();
8168
8169      // ── Reset view ────────────────────────────────────────────────────────
8170      window.resetView = function() {
8171        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8172        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8173        sortCol = null; sortOrder = 'asc';
8174        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8175        var tbody = document.getElementById('compare-tbody');
8176        if (tbody) {
8177          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8178          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8179          rows.forEach(function(r) { tbody.appendChild(r); });
8180        }
8181        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8182        var table = document.getElementById('compare-table');
8183        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8184        currentPage = 1; renderPage();
8185      };
8186
8187      renderPage();
8188
8189      (function randomizeWatermarks() {
8190        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8191        if (!wms.length) return;
8192        var placed = [];
8193        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;}
8194        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];}
8195        var half=Math.floor(wms.length/2);
8196        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+';';});
8197      })();
8198
8199      (function spawnCodeParticles() {
8200        var container = document.getElementById('code-particles');
8201        if (!container) return;
8202        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'];
8203        for (var i = 0; i < 38; i++) {
8204          (function(idx) {
8205            var el = document.createElement('span');
8206            el.className = 'code-particle';
8207            el.textContent = snippets[idx % snippets.length];
8208            var left = Math.random() * 94 + 2;
8209            var top = Math.random() * 88 + 6;
8210            var dur = (Math.random() * 10 + 9).toFixed(1);
8211            var delay = (Math.random() * 18).toFixed(1);
8212            var rot = (Math.random() * 26 - 13).toFixed(1);
8213            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8214            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8215            container.appendChild(el);
8216          })(i);
8217        }
8218      })();
8219    })();
8220
8221    var selected = [];
8222    function updateCompareBtn() {
8223      var btn = document.getElementById('compare-btn');
8224      var cnt = document.getElementById('sel-count');
8225      if (!btn) return;
8226      btn.disabled = selected.length !== 2;
8227      if (cnt) cnt.textContent = selected.length + '/2';
8228    }
8229
8230    function toggleRow(row, runId) {
8231      var idx = selected.indexOf(runId);
8232      if (idx >= 0) {
8233        selected.splice(idx, 1);
8234        row.classList.remove('selected');
8235        var b = document.getElementById('badge-' + runId);
8236        if (b) b.textContent = '';
8237      } else {
8238        if (selected.length >= 2) return;
8239        selected.push(runId);
8240        row.classList.add('selected');
8241        var b = document.getElementById('badge-' + runId);
8242        if (b) b.textContent = selected.length;
8243      }
8244      selected.forEach(function(id, i) {
8245        var b = document.getElementById('badge-' + id);
8246        if (b) b.textContent = i + 1;
8247      });
8248      updateCompareBtn();
8249    }
8250
8251    function doCompare() {
8252      if (selected.length !== 2) return;
8253      window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8254    }
8255  </script>
8256</body>
8257</html>
8258"##,
8259    ext = "html"
8260)]
8261struct CompareSelectTemplate {
8262    entries: Vec<HistoryEntryRow>,
8263    total_scans: usize,
8264}
8265
8266// ── CompareTemplate ────────────────────────────────────────────────────────────
8267
8268#[derive(Template)]
8269#[template(
8270    source = r##"
8271<!doctype html>
8272<html lang="en">
8273<head>
8274  <meta charset="utf-8">
8275  <meta name="viewport" content="width=device-width, initial-scale=1">
8276  <title>OxideSLOC | Scan Delta</title>
8277  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8278  <style>
8279    :root {
8280      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8281      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8282      --nav:#b85d33; --nav-2:#7a371b;
8283      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8284      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8285      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8286    }
8287    body.dark-theme {
8288      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8289      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8290    }
8291    *{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);}
8292    .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);}
8293    .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;}
8294    .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));}
8295    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8296    .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;}
8297    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8298    .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;}
8299    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8300    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8301    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8302    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8303    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8304    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8305    .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;}
8306    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8307    .hero-body{display:block;}
8308    .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;}
8309    .btn-back:hover{background:var(--line);}
8310    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8311    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8312    .muted{color:var(--muted);font-size:14px;}
8313    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8314    .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;}
8315    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8316    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8317    .vpill-arrow{font-size:20px;color:var(--muted);}
8318    .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%;}
8319    .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;}
8320    .delta-card.delta-card-wide{padding:14px 18px;}
8321    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8322    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8323    .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8324    .delta-card-from{font-size:12px;color:var(--muted);}
8325    .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8326    .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;}
8327    .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);}
8328    .delta-card:hover .dc-tip{display:block;}
8329    .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;}
8330    .export-btn:hover{background:var(--line);}
8331    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8332    .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8333    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8334    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8335    .delta-card-change.zero{color:var(--muted);background:transparent;}
8336    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8337    .fc-row{display:flex;align-items:center;gap:8px;}
8338    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8339    .fc-label{color:var(--muted);}
8340    .fc-modified .fc-count{color:#926000;}
8341    .fc-added .fc-count{color:var(--pos);}
8342    .fc-removed .fc-count{color:var(--neg);}
8343    .fc-unchanged .fc-count{color:var(--muted);}
8344    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8345    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8346    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8347    .chip.modified{background:#fff2d8;color:#926000;}
8348    .chip.added{background:#e8f5ed;color:#1a8f47;}
8349    .chip.removed{background:#fdeaea;color:#b33b3b;}
8350    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8351    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8352    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8353    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8354    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8355    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8356    .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;}
8357    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8358    .tab-btn:hover:not(.active){background:var(--line);}
8359    .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;}
8360    .btn-reset:hover{background:var(--line);}
8361    .table-wrap{width:100%;overflow-x:auto;}
8362    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8363    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;}
8364    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8365    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8366    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8367    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8368    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8369    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8370    tr:last-child td{border-bottom:none;}
8371    tr.row-added td{background:rgba(26,143,71,0.06);}
8372    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8373    tr.row-modified td{background:rgba(146,96,0,0.05);}
8374    tr.row-unchanged td{opacity:.6;}
8375    .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8376    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8377    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8378    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8379    .status-badge.modified{background:#fff2d8;color:#926000;}
8380    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8381    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8382    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8383    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8384    .delta-val{font-weight:700;}
8385    .delta-val.pos{color:var(--pos);}
8386    .delta-val.neg{color:var(--neg);}
8387    .delta-val.zero{color:var(--muted);}
8388    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8389    .from-to strong{color:var(--text);}
8390    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8391    .site-footer a{color:var(--muted);}
8392    @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8393    @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8394    @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8395    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8396    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8397    .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;}
8398    .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;}
8399    .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;}
8400    @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));}}
8401    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8402    .path-link:hover{color:var(--oxide-2);}
8403    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8404    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8405    a.vpill-id:hover{color:var(--oxide);}
8406    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8407    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8408    .pagination-info{font-size:13px;color:var(--muted);}
8409    .pagination-btns{display:flex;gap:6px;}
8410    .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;}
8411    .pg-btn:hover:not(:disabled){background:var(--line);}
8412    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8413    .pg-btn:disabled{opacity:.35;cursor:default;}
8414    .per-page-label{font-size:13px;color:var(--muted);}
8415    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;}
8416    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8417    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8418    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8419    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8420    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8421    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8422    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8423    .tab-btn.tab-unchanged{color:var(--muted);}
8424    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8425    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8426    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8427  </style>
8428</head>
8429<body>
8430  <div class="background-watermarks" aria-hidden="true">
8431    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
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  </div>
8438  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8439  <div class="top-nav">
8440    <div class="top-nav-inner">
8441      <a class="brand" href="/">
8442        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8443        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8444      </a>
8445      <div class="nav-right">
8446        <a class="nav-pill" href="/">Home</a>
8447        <a class="nav-pill" href="/view-reports">View Reports</a>
8448        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8449        <div class="server-status-wrap">
8450          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8451          <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>
8452        </div>
8453        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8454          <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>
8455          <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>
8456        </button>
8457      </div>
8458    </div>
8459  </div>
8460
8461  <div class="page">
8462    <section class="hero">
8463      <div class="hero-header">
8464        <div>
8465          <h1 style="margin:0 0 6px;">Scan Delta</h1>
8466          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8467            <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8468            <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>
8469          </div>
8470          <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8471            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8472              <span style="color:var(--text);font-weight:700;">Baseline</span>&nbsp;&nbsp;{{ baseline_timestamp }}
8473            </span>
8474            <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>
8475            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8476              <span style="color:var(--oxide);font-weight:700;">Current</span>&nbsp;&nbsp;{{ current_timestamp }}
8477            </span>
8478          </div>
8479        </div>
8480        <a class="btn-back" href="/compare-scans">
8481          <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>
8482          Compare Scans
8483        </a>
8484      </div>
8485      <div class="hero-body">
8486      <div class="delta-strip">
8487        <div class="delta-card delta-card-meta">
8488          <div class="delta-card-label">Baseline</div>
8489          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8490          <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8491          {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8492          {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8493          {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8494        </div>
8495        <div class="delta-card delta-card-meta">
8496          <div class="delta-card-label">Current</div>
8497          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
8498          <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
8499          {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
8500          {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8501          {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8502        </div>
8503        <div class="delta-card">
8504          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
8505          <div class="delta-card-label">Code lines</div>
8506          <div class="delta-card-from">Before: {{ baseline_code }}</div>
8507          <div class="delta-card-to">{{ current_code }}</div>
8508          {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
8509          {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
8510          {% endif %}
8511        </div>
8512        <div class="delta-card">
8513          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
8514          <div class="delta-card-label">Files analyzed</div>
8515          <div class="delta-card-from">Before: {{ baseline_files }}</div>
8516          <div class="delta-card-to">{{ current_files }}</div>
8517          {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
8518          {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
8519          {% endif %}
8520        </div>
8521        <div class="delta-card">
8522          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
8523          <div class="delta-card-label">Comment lines</div>
8524          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
8525          <div class="delta-card-to">{{ current_comments }}</div>
8526          {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
8527          {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
8528          {% endif %}
8529        </div>
8530        <div class="delta-card delta-card-wide">
8531          <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>
8532          <div class="delta-card-label">File changes</div>
8533          <div class="file-changes-grid">
8534            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
8535            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
8536            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
8537            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
8538          </div>
8539        </div>
8540      </div>
8541      </div>
8542    </section>
8543
8544    <section class="panel">
8545      <h2>File-level delta</h2>
8546      <div class="filter-tabs-row">
8547        <div class="filter-tabs">
8548          <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
8549          <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
8550          <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
8551          <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
8552          <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
8553        </div>
8554        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
8555          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
8556          <div class="export-group">
8557            <button type="button" class="btn-reset" onclick="resetDeltaTable()">&#8635; Reset</button>
8558            <button type="button" class="export-btn" onclick="exportDeltaCsv()">
8559              <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>
8560              CSV
8561            </button>
8562            <button type="button" class="export-btn" onclick="exportDeltaXls()">
8563              <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>
8564              Excel
8565            </button>
8566            <button type="button" class="export-btn" onclick="exportDeltaCharts()">
8567              <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>
8568              Charts
8569            </button>
8570          </div>
8571        </div>
8572      </div>
8573
8574      <div class="table-wrap">
8575      <table id="delta-table">
8576        <colgroup>
8577          <col style="width:34%">
8578          <col style="width:10%">
8579          <col style="width:9%">
8580          <col style="width:15%">
8581          <col style="width:8%">
8582          <col style="width:8%">
8583          <col style="width:8%">
8584        </colgroup>
8585        <thead>
8586          <tr id="delta-thead">
8587            <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>
8588            <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>
8589            <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>
8590            <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>
8591            <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>
8592            <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>
8593            <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>
8594          </tr>
8595        </thead>
8596        <tbody id="delta-tbody">
8597          {% for row in file_rows %}
8598          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
8599              data-path="{{ row.relative_path }}"
8600              data-language="{{ row.language }}"
8601              data-baseline-code="{{ row.baseline_code }}"
8602              data-current-code="{{ row.current_code }}"
8603              data-code-delta="{{ row.code_delta_str }}"
8604              data-comment-delta="{{ row.comment_delta_str }}"
8605              data-total-delta="{{ row.total_delta_str }}"
8606              data-orig-idx="">
8607            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
8608            <td class="hide-sm">{{ row.language }}</td>
8609            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
8610            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
8611            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
8612            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
8613            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
8614          </tr>
8615          {% endfor %}
8616        </tbody>
8617      </table>
8618      </div>
8619      <div class="pagination">
8620        <span class="pagination-info" id="pg-info"></span>
8621        <div class="pagination-btns" id="pg-btns"></div>
8622        <div style="display:flex;align-items:center;gap:8px;">
8623          <span class="per-page-label">Show</span>
8624          <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
8625            <option value="10">10 per page</option>
8626            <option value="25" selected>25 per page</option>
8627            <option value="50">50 per page</option>
8628            <option value="100">100 per page</option>
8629          </select>
8630          <span class="per-page-label" id="pg-range-label"></span>
8631        </div>
8632      </div>
8633    </section>
8634  </div>
8635
8636  <footer class="site-footer">
8637    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8638    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8639    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8640  </footer>
8641
8642  <script>
8643    (function () {
8644      var storageKey = 'oxide-sloc-theme';
8645      var body = document.body;
8646      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8647      var toggle = document.getElementById('theme-toggle');
8648      if (toggle) toggle.addEventListener('click', function () {
8649        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8650        body.classList.toggle('dark-theme', next === 'dark');
8651        try { localStorage.setItem(storageKey, next); } catch(e) {}
8652      });
8653
8654      (function randomizeWatermarks() {
8655        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8656        if (!wms.length) return;
8657        var placed = [];
8658        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;}
8659        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];}
8660        var half=Math.floor(wms.length/2);
8661        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+';';});
8662      })();
8663
8664      (function spawnCodeParticles() {
8665        var container = document.getElementById('code-particles');
8666        if (!container) return;
8667        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'];
8668        for (var i = 0; i < 38; i++) {
8669          (function(idx) {
8670            var el = document.createElement('span');
8671            el.className = 'code-particle';
8672            el.textContent = snippets[idx % snippets.length];
8673            var left = Math.random() * 94 + 2;
8674            var top = Math.random() * 88 + 6;
8675            var dur = (Math.random() * 10 + 9).toFixed(1);
8676            var delay = (Math.random() * 18).toFixed(1);
8677            var rot = (Math.random() * 26 - 13).toFixed(1);
8678            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8679            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8680            container.appendChild(el);
8681          })(i);
8682        }
8683      })();
8684    })();
8685
8686    var activeStatusFilter = 'all';
8687    var deltaPerPage = 25, deltaCurrPage = 1;
8688
8689    function openFolder(path) {
8690      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
8691    }
8692
8693    function getDeltaFilteredRows() {
8694      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
8695        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
8696      });
8697    }
8698
8699    function renderDeltaPage() {
8700      var filtered = getDeltaFilteredRows();
8701      var total = filtered.length;
8702      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
8703      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
8704      var start = (deltaCurrPage - 1) * deltaPerPage;
8705      var end = Math.min(start + deltaPerPage, total);
8706      var shownSet = {};
8707      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
8708      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
8709        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
8710      });
8711      var rl = document.getElementById('pg-range-label');
8712      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8713      var info = document.getElementById('pg-info');
8714      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
8715      var btns = document.getElementById('pg-btns');
8716      if (!btns) return;
8717      btns.innerHTML = '';
8718      if (totalPages <= 1) return;
8719      function makeBtn(lbl, pg, active, disabled) {
8720        var b = document.createElement('button');
8721        b.className = 'pg-btn' + (active ? ' active' : '');
8722        b.textContent = lbl; b.disabled = disabled;
8723        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
8724        return b;
8725      }
8726      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
8727      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8728      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
8729      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
8730    }
8731
8732    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
8733
8734    function filterRows(status, btn) {
8735      activeStatusFilter = status;
8736      deltaCurrPage = 1;
8737      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
8738        b.classList.remove('active');
8739      });
8740      if (btn) btn.classList.add('active');
8741      renderDeltaPage();
8742    }
8743
8744    // ── Sorting ──────────────────────────────────────────────────────────────
8745    var sortCol = null, sortOrder = 'asc';
8746    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
8747    (function() {
8748      var tbody = document.getElementById('delta-tbody');
8749      if (!tbody) return;
8750      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8751      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
8752    })();
8753
8754    function parseDeltaNum(str) {
8755      if (!str || str === '—') return 0;
8756      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
8757    }
8758
8759    sortHeaders.forEach(function(th) {
8760      th.addEventListener('click', function(e) {
8761        if (e.target.classList.contains('col-resize-handle')) return;
8762        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8763        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8764        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8765        th.classList.add('sort-' + sortOrder);
8766        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8767        var tbody = document.getElementById('delta-tbody');
8768        if (!tbody) return;
8769        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8770        rows.sort(function(a, b) {
8771          var va, vb;
8772          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
8773          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
8774          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
8775          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
8776          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8777          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8778          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8779          else { va = ''; vb = ''; }
8780          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8781          return va < vb ? 1 : va > vb ? -1 : 0;
8782        });
8783        rows.forEach(function(r) { tbody.appendChild(r); });
8784        deltaCurrPage = 1;
8785        renderDeltaPage();
8786        var activeBtn = document.querySelector('.tab-btn.active');
8787        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8788        if (activeBtn) activeBtn.classList.add('active');
8789      });
8790    });
8791
8792    // ── Column resize ─────────────────────────────────────────────────────────
8793    (function() {
8794      var table = document.getElementById('delta-table');
8795      if (!table) return;
8796      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8797      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
8798      ths.forEach(function(th, i) {
8799        var handle = th.querySelector('.col-resize-handle');
8800        if (!handle || !cols[i]) return;
8801        var startX, startW;
8802        handle.addEventListener('mousedown', function(e) {
8803          e.stopPropagation(); e.preventDefault();
8804          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8805          handle.classList.add('dragging');
8806          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8807          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8808          document.addEventListener('mousemove', onMove);
8809          document.addEventListener('mouseup', onUp);
8810        });
8811      });
8812    })();
8813
8814    // ── Reset ─────────────────────────────────────────────────────────────────
8815    window.resetDeltaTable = function() {
8816      sortCol = null; sortOrder = 'asc';
8817      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8818      var tbody = document.getElementById('delta-tbody');
8819      if (tbody) {
8820        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8821        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8822        rows.forEach(function(r) { tbody.appendChild(r); });
8823      }
8824      var table = document.getElementById('delta-table');
8825      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8826      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
8827      activeStatusFilter = 'all';
8828      deltaCurrPage = 1;
8829      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8830      var allBtn = document.querySelector('.tab-btn');
8831      if (allBtn) allBtn.classList.add('active');
8832      renderDeltaPage();
8833    };
8834
8835    renderDeltaPage();
8836
8837    // ── Export helpers ────────────────────────────────────────────────────────
8838    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
8839    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8840    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);}
8841    function slocMakeXlsx(fname,sd,dr){
8842      var enc=new TextEncoder();
8843      // CRC-32 table
8844      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;}
8845      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;}
8846      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
8847      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
8848      // Shared string table
8849      var ss=[],si={};
8850      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
8851      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8852      // Worksheet builder — each WS() call gets its own row counter R
8853      function WS(){
8854        var R=0,buf=[];
8855        function cl(c){return String.fromCharCode(65+c);}
8856        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
8857          '<v>'+S(v)+'</v></c>';}
8858        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
8859          (st?' s="'+st+'"':'')+'>'+
8860          '<v>'+(+v)+'</v></c>';}
8861        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
8862        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8863          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
8864          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
8865          '<sheetFormatPr defaultRowHeight="15"/>'+
8866          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
8867        return{sc:sc,nc:nc,row:row,xml:xml};
8868      }
8869      // Language breakdown
8870      var lm={};
8871      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;});
8872      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
8873      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
8874      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
8875      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
8876      // Summary sheet
8877      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
8878      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
8879      r1(s1(0,proj,2));
8880      r1(s1(0,sd.bts+' → '+sd.cts,2));
8881      r1('');
8882      r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
8883      r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
8884      r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
8885      r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
8886      r1('');
8887      r1(s1(0,'FILE CHANGES',8));
8888      r1(s1(0,'Category',3)+s1(3,'Count',3));
8889      r1(s1(0,'Modified')+n1(3,sd.fm,4));
8890      r1(s1(0,'Added')+n1(3,sd.fa,4));
8891      r1(s1(0,'Removed')+n1(3,sd.fr,4));
8892      r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
8893      if(langs.length){
8894        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
8895        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
8896        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)));});
8897      }
8898      r1('');r1(s1(0,'SCAN METADATA',8));
8899      r1(s1(1,'Baseline')+s1(2,'Current'));
8900      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
8901      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
8902      var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
8903      // File Delta sheet
8904      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
8905      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));
8906      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])));});
8907      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
8908      // Shared strings XML
8909      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8910        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
8911        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
8912      // XLSX file map
8913      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
8914      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>',
8915        '_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>',
8916        '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>',
8917        '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>',
8918        '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>',
8919        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
8920      // ZIP packer — STORED (no compression), compatible with all XLSX readers
8921      var zparts=[],zcds=[],zoff=0,znf=0;
8922      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
8923       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
8924      ].forEach(function(name){
8925        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
8926        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]);
8927        var entry=new Uint8Array(lha.length+nb.length+sz);
8928        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
8929        zparts.push(entry);
8930        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));
8931        var cde=new Uint8Array(cda.length+nb.length);
8932        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
8933        zcds.push(cde);zoff+=entry.length;znf++;
8934      });
8935      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
8936      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]);
8937      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
8938      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
8939      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
8940      zout.set(new Uint8Array(ea),zpos);
8941      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
8942      var xurl=URL.createObjectURL(xblob);
8943      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
8944      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
8945      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
8946    }
8947    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;');}
8948    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;}
8949
8950    var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
8951    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 }}'};
8952    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)]];}
8953    var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
8954    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;}
8955    window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
8956    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
8957
8958    // ── Chart HTML report ─────────────────────────────────────────────────────
8959    function slocChartReport(fname, sd, dr) {
8960      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
8961      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8962      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
8963      function fmt(n){return Number(n).toLocaleString();}
8964      function px(n){return Math.round(n);}
8965      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
8966      // Language map
8967      var lm={};
8968      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;});
8969      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
8970
8971      // Builds onmouse* attrs for interactive tooltip on each SVG element
8972      function barTT(label,val){
8973        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
8974      }
8975
8976      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
8977      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}];
8978      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
8979      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
8980      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
8981      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
8982      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"/>';}
8983      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
8984      c1mets.forEach(function(m,i){
8985        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
8986        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
8987        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>';
8988        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))+'/>';
8989        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>';
8990        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))+'/>';
8991        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>';
8992        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>';
8993        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>';
8994      });
8995      c1+='</svg>';
8996
8997      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
8998      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}];
8999      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9000      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9001      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9002      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9003      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9004      mets.forEach(function(m,i){
9005        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9006        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9007        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9008        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>';
9009        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9010        if(bw>=52){
9011          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>';
9012        }else{
9013          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9014          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>';
9015        }
9016      });
9017      c2+='</svg>';
9018
9019      // ── Chart 3: Language Code Delta ─────────────────────────────────────
9020      var c3='';
9021      if(langs.length){
9022        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9023        var C3W=550,c3LW=124,c3FW=52;
9024        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9025        var L3rH=30,C3H=langs.length*L3rH+20;
9026        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9027        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9028        langs.forEach(function(l,i){
9029          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9030          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9031          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9032          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9033          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':''))+'/>';
9034          if(bw>=48){
9035            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>';
9036          }else{
9037            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9038            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>';
9039          }
9040          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>';
9041        });
9042        c3+='</svg>';
9043      }
9044
9045      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9046      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;});
9047      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9048      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9049      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9050      var ang=-Math.PI/2;
9051      segs.forEach(function(s){
9052        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9053        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9054        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9055        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9056        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9057        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)+'%')+'/>';
9058        ang+=sw;
9059      });
9060      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>';
9061      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9062      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>';});
9063      c4+='</svg>';
9064
9065      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9066      var ttJs='var tt=document.getElementById("ox-tt");'+
9067        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9068        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9069        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9070        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9071        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9072        'function oxHT(){tt.style.display="none";}';
9073
9074      // body max-width keeps charts from inflating beyond design dimensions on
9075      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9076      // each chart's height blows up proportionally, breaking the one-page layout.
9077      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;}'+
9078        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9079        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9080        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9081        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9082        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9083        'svg{display:block;}'+
9084        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9085        '#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;}'+
9086        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9087      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9088        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9089        '<div id="ox-tt"><\/div>'+
9090        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
9091        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
9092        '<div class="two-col">'+
9093        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
9094        '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9095        '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9096        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9097        '<\/div>'+
9098        '<div class="two-col">'+
9099        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9100        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9101        '<\/div>'+
9102        '<script>'+ttJs+'<\/script>'+
9103        '<\/body><\/html>';
9104      slocDownload(html, fname, 'text/html;charset=utf-8;');
9105    }
9106    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9107  </script>
9108</body>
9109</html>
9110"##,
9111    ext = "html"
9112)]
9113struct CompareTemplate {
9114    baseline_run_id: String,
9115    current_run_id: String,
9116    baseline_run_id_short: String,
9117    current_run_id_short: String,
9118    baseline_timestamp: String,
9119    current_timestamp: String,
9120    project_path: String,
9121    baseline_code: u64,
9122    current_code: u64,
9123    code_lines_delta_str: String,
9124    code_lines_delta_class: String,
9125    baseline_files: u64,
9126    current_files: u64,
9127    files_analyzed_delta_str: String,
9128    files_analyzed_delta_class: String,
9129    baseline_comments: u64,
9130    current_comments: u64,
9131    comment_lines_delta_str: String,
9132    comment_lines_delta_class: String,
9133    files_added: usize,
9134    files_removed: usize,
9135    files_modified: usize,
9136    files_unchanged: usize,
9137    file_rows: Vec<CompareFileDeltaRow>,
9138    baseline_git_author: Option<String>,
9139    current_git_author: Option<String>,
9140    baseline_git_branch: String,
9141    current_git_branch: String,
9142    baseline_git_tags: Option<String>,
9143    current_git_tags: Option<String>,
9144}