← Commit history

v0.4.0: loading splash (animated mark + Fetching Projects %), board version selector (render/<version> archive + dropdown), red/green visibility tags, org Your/Other split + author names + tag filter chips, gold molecule tag, evenodd fill transparency, model_3d GLB 3D, silk-skip

barrett-land ·8c6cca3faf ·3mo ago ·parent e1f618c
4 files changed +283−261
Cargo.lock−212
@@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]]-name = "adler2"-version = "2.0.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"- [[package]] name = "adom-project-manager" version = "0.1.0"@@ -23,33 +17,11 @@ dependencies = [ name = "altium-codec" version = "0.1.0" dependencies = [- "altium-pcblib",- "altium-schlib",  "anyhow",- "cfb",- "flate2",  "serde",  "serde_json", ] -[[package]]-name = "altium-pcblib"-version = "0.1.0"-dependencies = [- "anyhow",- "cfb",- "serde_json",-]--[[package]]-name = "altium-schlib"-version = "0.1.0"-dependencies = [- "anyhow",- "cfb",- "serde_json",-]- [[package]] name = "anyhow" version = "1.0.103"@@ -62,90 +34,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]]-name = "bumpalo"-version = "3.20.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"--[[package]]-name = "byteorder"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"--[[package]]-name = "cfb"-version = "0.10.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"-dependencies = [- "byteorder",- "fnv",- "uuid",-]--[[package]]-name = "cfg-if"-version = "1.0.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"- [[package]] name = "chunked_transfer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" -[[package]]-name = "crc32fast"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"-dependencies = [- "cfg-if",-]--[[package]]-name = "flate2"-version = "1.1.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"-dependencies = [- "crc32fast",- "miniz_oxide",-]--[[package]]-name = "fnv"-version = "1.0.7"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"--[[package]]-name = "futures-core"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"--[[package]]-name = "futures-task"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"--[[package]]-name = "futures-util"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"-dependencies = [- "futures-core",- "futures-task",- "pin-project-lite",- "slab",-]- [[package]] name = "httpdate" version = "1.0.3"@@ -158,17 +52,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]]-name = "js-sys"-version = "0.3.103"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"-dependencies = [- "cfg-if",- "futures-util",- "wasm-bindgen",-]- [[package]] name = "log" version = "0.4.33"@@ -181,28 +64,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]]-name = "miniz_oxide"-version = "0.8.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"-dependencies = [- "adler2",- "simd-adler32",-]--[[package]]-name = "once_cell"-version = "1.21.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"--[[package]]-name = "pin-project-lite"-version = "0.2.17"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"- [[package]] name = "proc-macro2" version = "1.0.106"@@ -221,12 +82,6 @@ dependencies = [  "proc-macro2", ] -[[package]]-name = "rustversion"-version = "1.0.23"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"- [[package]] name = "serde" version = "1.0.228"@@ -270,18 +125,6 @@ dependencies = [  "zmij", ] -[[package]]-name = "simd-adler32"-version = "0.3.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"--[[package]]-name = "slab"-version = "0.4.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"- [[package]] name = "syn" version = "2.0.118"@@ -311,61 +154,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]]-name = "uuid"-version = "1.23.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"-dependencies = [- "js-sys",- "wasm-bindgen",-]--[[package]]-name = "wasm-bindgen"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"-dependencies = [- "cfg-if",- "once_cell",- "rustversion",- "wasm-bindgen-macro",- "wasm-bindgen-shared",-]--[[package]]-name = "wasm-bindgen-macro"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"-dependencies = [- "quote",- "wasm-bindgen-macro-support",-]--[[package]]-name = "wasm-bindgen-macro-support"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"-dependencies = [- "bumpalo",- "proc-macro2",- "quote",- "syn",- "wasm-bindgen-shared",-]--[[package]]-name = "wasm-bindgen-shared"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"-dependencies = [- "unicode-ident",-]- [[package]] name = "zmij" version = "1.0.21"
src/loader.gifadded
⋯ 1 unchanged line ⋯
src/main.rs+131−29
@@ -67,23 +67,75 @@ fn render_and_upload(slug: &str) -> bool {     for (i, (_, svg)) in sheets.iter().enumerate() {         let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg);     }+    let sheet_names: Vec<String> = sheets.iter().map(|(n, _)| n.clone()).collect();++    // ---- version archive -------------------------------------------------+    // Content-hash the just-rendered assets. A new version is archived ONLY when+    // the render actually differs from the last archived one (a deps/metadata-only+    // republish reuses the prior version → no duplicate visual version). Older+    // render/<version>/ folders on the page are preserved (we push targeted paths).+    let new_hash = {+        use std::hash::{Hash, Hasher};+        let mut h = std::collections::hash_map::DefaultHasher::new();+        if let Some(g) = &glb { g.hash(&mut h); }+        if let Some(s) = &pcb_svg { s.as_bytes().hash(&mut h); }+        for (_, svg) in &sheets { svg.as_bytes().hash(&mut h); }+        sheet_names.hash(&mut h);+        format!("{:016x}", h.finish())+    };+    let version = wiki_text(slug, "package.json")+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))+        .unwrap_or_else(|| "0.0.0".to_string());+    // prior versions[] from the existing manifest (empty on first archive / legacy pages)+    let mut versions: Vec<Value> = wiki_bytes(slug, "render/manifest.json")+        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+        .and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())+        .unwrap_or_default();+    let latest_hash = versions.first().and_then(|v| v.get("hash").and_then(|h| h.as_str())).unwrap_or("");+    let changed = latest_hash != new_hash;+    if changed {+        // stage this version's assets under render/<version>/ and index it+        let vdir = format!("{rdir}/{version}");+        let _ = std::fs::create_dir_all(&vdir);+        if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }+        if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }+        for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }+        // drop any existing entry for this version string, then prepend the fresh one+        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version.as_str()));+        versions.insert(0, json!({"version": version, "hash": new_hash,+            "glb": glb.is_some(), "pcb": pcb_svg.is_some(), "sheets": sheet_names}));+    }+    let current = versions.first().and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))+        .unwrap_or_else(|| version.clone());     let manifest = json!({-        "v": 1,+        "v": 2,+        "current": current,         "glb": glb.is_some(),         "pcb": pcb_svg.is_some(),-        "sheets": sheets.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),+        "sheets": sheet_names,+        "versions": versions,     });     let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());-    // repo push the render/ folder (bare slug works for org-owned pages too)++    // Push TARGETED paths (not the whole render/ dir) so previously-archived+    // render/<version>/ folders on the page are not clobbered.     let bare = slug.rsplit('/').next().unwrap_or(slug);-    let ok = Command::new("adom-wiki")-        .args(["repo", "push", bare, "--files", "render", "-m",-            "regenerate board render assets (3D GLB + PCB/schematic SVGs)"])-        .current_dir(&work)-        .status()-        .map(|s| s.success())-        .unwrap_or(false);-    eprintln!("{}", if ok { "uploaded render/ assets" } else { "upload FAILED" });+    let mut files: Vec<String> = vec!["render/manifest.json".into()];+    if glb.is_some() { files.push("render/board.glb".into()); }+    if pcb_svg.is_some() { files.push("render/pcb.svg".into()); }+    for i in 0..sheets.len() { files.push(format!("render/sheet-{i:03}.svg")); }+    if changed { files.push(format!("render/{version}")); }+    let mut args: Vec<String> = vec!["repo".into(), "push".into(), bare.into(), "--files".into()];+    args.extend(files);+    args.push("-m".into());+    args.push(format!("render assets{}", if changed { format!(" + archive v{version}") } else { String::new() }));+    let ok = Command::new("adom-wiki").args(&args).current_dir(&work)+        .status().map(|s| s.success()).unwrap_or(false);+    let msg = if !ok { "upload FAILED".to_string() }+        else if changed { format!("uploaded render/ + archived v{version}") }+        else { "uploaded render/ (no content change → no new version)".to_string() };+    eprintln!("{msg}");     ok } @@ -213,6 +265,9 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             json_resp(&list_dirs(&pp).to_string())         }         (Method::Get, "/") => html(INDEX_HTML),+        (Method::Get, "/loader.gif") => Response::from_data(LOADER_GIF.to_vec())+            .with_header(hdr("image/gif"))+            .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()),         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear             if url.contains("refresh=1") {@@ -241,32 +296,43 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             json_resp(&board_media(slug).to_string())         }         (Method::Get, p) if p.starts_with("/board-svg/") => {-            // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>-            let segs: Vec<&str> = p["/board-svg/".len()..].splitn(3, '/').collect();+            // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>  [?v=<version>]+            let (path, ver) = split_ver(p);+            let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();             let slug = segs.first().copied().unwrap_or("");-            // live in-memory render first; else a stored render/ asset from the page-            let svg = cached_board(slug)-                .and_then(|(sheets, pcb, _)| match segs.get(1).copied() {+            let sub = |dir: &str| match segs.get(1).copied() {+                Some("pcb") => Some(format!("{dir}pcb.svg")),+                Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("{dir}sheet-{i:03}.svg")),+                _ => None,+            };+            // a selected PAST version → serve from its render/<version>/ archive;+            // else the live in-memory render; else the current stored top-level render/.+            let svg = ver.as_ref()+                .and_then(|v| sub(&format!("render/{v}/")))+                .and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())+                .or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match segs.get(1).copied() {                     Some("pcb") => pcb,                     Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),                     _ => None,-                })-                .or_else(|| {-                    let file = match segs.get(1).copied() {-                        Some("pcb") => Some("render/pcb.svg".to_string()),-                        Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("render/sheet-{i:03}.svg")),-                        _ => None,-                    };-                    file.and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())-                });+                }))+                .or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));             match svg {                 Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")),                 None => Response::from_string("not rendered").with_status_code(404),             }         }         (Method::Get, p) if p.starts_with("/board-glb/") => {-            let slug = &p["/board-glb/".len()..];-            let glb = cached_board(slug).and_then(|(_, _, g)| g).or_else(|| wiki_bytes(slug, "render/board.glb"));+            let (path, ver) = split_ver(p);+            let slug = &path["/board-glb/".len()..];+            // A selected PAST version → its archived render/<version>/board.glb. Otherwise+            // serve the SAME 3D the wiki page shows: the page's model_3d GLB (Drew's+            // molecule-publish emits `<slug>.glb` and points model_3d_path at it), so the+            // Manager's 3D matches the wiki page. Fall back to live cache, then render/board.glb.+            let glb = ver.as_ref()+                .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))+                .or_else(|| page_model_glb(slug))+                .or_else(|| cached_board(slug).and_then(|(_, _, g)| g))+                .or_else(|| wiki_bytes(slug, "render/board.glb"));             match glb {                 Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")),                 None => Response::from_string("not rendered").with_status_code(404),@@ -334,6 +400,36 @@ fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {     curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}")) } +/// The page's model_3d GLB — the exact 3D file the wiki page displays. Drew's+/// molecule-publish emits `<slug>.glb` and sets `model_3d_path` to it, so serving+/// this keeps the Manager's 3D view consistent with the wiki page. Prefers the+/// explicit `model_3d_path` from package.json, else the `<slug>.glb` convention.+fn page_model_glb(slug: &str) -> Option<Vec<u8>> {+    if let Some(txt) = wiki_text(slug, "package.json") {+        if let Ok(v) = serde_json::from_str::<Value>(&txt) {+            if let Some(p) = v.get("model_3d_path").and_then(|x| x.as_str()).filter(|s| !s.is_empty()) {+                let p = p.trim_start_matches("./").trim_start_matches('/');+                if let Some(b) = wiki_bytes(slug, p) {+                    return Some(b);+                }+            }+        }+    }+    wiki_bytes(slug, &format!("{slug}.glb"))+}++/// Split a request path into (path_without_query, the `?v=<version>` value if present).+/// Used by the board asset routes to serve a past version from `render/<version>/`.+fn split_ver(p: &str) -> (&str, Option<String>) {+    match p.split_once('?') {+        Some((path, q)) => {+            let v = q.split('&').find_map(|kv| kv.strip_prefix("v=")).map(|s| s.to_string());+            (path, v.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/')))+        }+        None => (p, None),+    }+}+ /// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`). fn up(slug: &str) -> String {     slug.to_uppercase()@@ -375,7 +471,9 @@ fn list_components() -> Value {             let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();             let org = pg.get("org_name").and_then(|s| s.as_str()).unwrap_or("").to_string();             let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false);-            items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred}));+            let visibility = pg.get("visibility").and_then(|s| s.as_str()).unwrap_or("").to_string();+            let version = pg.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();+            items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version}));         }         offset += n;         let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;@@ -1609,7 +1707,10 @@ fn board_media(slug: &str) -> Value {             .unwrap_or_default();         return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets,             "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),-            "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false)});+            "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false),+            // version archive (empty on legacy flat manifests → the UI shows single-current)+            "versions": m.get("versions").cloned().unwrap_or_else(|| json!([])),+            "current": m.get("current").cloned().unwrap_or(Value::Null)});     }     // no stored render/ folder from here on — the board live-renders instead.     if let Some((sheets, pcb, glb)) = cached_board(slug) {@@ -2236,3 +2337,4 @@ fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> { }  const INDEX_HTML: &str = include_str!("manage.html");+const LOADER_GIF: &[u8] = include_bytes!("loader.gif");
src/manage.html+152−20
@@ -23,12 +23,27 @@   .row .row-tags{margin-top:4px}   .row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}   .tag.kind.board{background:#16324f;color:#79b8ff}-  .tag.kind.molecule{background:#3d3416;color:#f5c518}+  .tag.kind.molecule{background:#463307;color:#f0a81c}   .tag.eda:empty{display:none}   .tag.eda.kicad{background:#0b2036;color:#5c9ce6}   .tag.eda.altium{background:#3d3416;color:#f5c518}   .tag.eda.fusion{background:#452510;color:#ff8c33}+  .tag.vis{text-transform:capitalize}+  .tag.vis.private{background:#3d1416;color:#f87171}+  .tag.vis.public{background:#123322;color:#4ade80}+  .tagfilter{display:flex;flex-wrap:wrap;gap:5px;padding:2px 16px 8px}+  .fchip{font-size:10px;font-weight:600;border-radius:5px;padding:2px 8px;cursor:pointer;border:1px solid var(--edge);background:#12151c;color:var(--mut);user-select:none;text-transform:capitalize;transition:background .12s,color .12s,border-color .12s}+  .fchip:hover{border-color:var(--acc)}+  .fchip.on{border-color:transparent}+  .fchip.on.molecule{background:#463307;color:#f0a81c}+  .fchip.on.board{background:#16324f;color:#79b8ff}+  .fchip.on.kicad{background:#0b2036;color:#5c9ce6}+  .fchip.on.altium{background:#3d3416;color:#f5c518}+  .fchip.on.fusion{background:#452510;color:#ff8c33}   .cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}+  .subhead{padding:6px 16px 4px 20px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);background:#0f1218;font-weight:700}+  .row .sub .auth{color:#7f8a9e}+  .row .sub .auth::before{content:'· '}   .tabs{display:flex;border-bottom:1px solid var(--edge)}   .tabs .tab{flex:1;background:none;border:none;border-bottom:2px solid transparent;color:var(--mut);padding:10px 4px;font-size:12px;cursor:pointer;white-space:nowrap}   .tabs .tab:hover{color:var(--ink)}@@ -49,6 +64,14 @@   #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}   #toolbar button:disabled{opacity:.45;cursor:not-allowed}   .modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}+  /* first-load splash: spinning adom mark + fetch progress */+  #app-loader{position:fixed;inset:0;background:var(--bg);z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;transition:opacity .45s ease}+  #app-loader.hide{opacity:0;pointer-events:none}+  #app-loader img{width:150px;height:auto;image-rendering:auto}+  #app-loader .lt{font-size:21px;font-weight:700;color:var(--ink);letter-spacing:.01em;margin-top:4px}+  #app-loader .lt span{font-weight:400;color:var(--mut)}+  #app-loader .lp{color:var(--mut);font-size:13px;letter-spacing:.04em;font-variant-numeric:tabular-nums;margin-top:2px}+  #app-loader .lp b{color:var(--ink);font-weight:600}   .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}   .modal h2{margin:0 0 4px;font-size:18px}   .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}@@ -129,6 +152,18 @@   .bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)}   .bo-head .title{margin:0;font-size:20px;font-weight:700}   .bo-author{color:var(--mut);font-size:13px;margin-top:3px}+  .bo-verwrap{position:relative;display:inline-block;margin-left:8px;vertical-align:3px}+  .bo-ver{position:relative;display:inline-block;font-size:11px;font-weight:600;color:var(--acc);background:#12151c;border:1px solid var(--edge);border-radius:6px;padding:2px 8px;cursor:pointer;user-select:none}+  .bo-ver:hover{border-color:var(--acc)}+  .bo-ver.static{cursor:default}+  .bo-ver.static:hover{border-color:var(--edge)}+  .bo-verdrop{display:none;position:absolute;top:calc(100% + 5px);left:0;min-width:170px;background:var(--panel);border:1px solid var(--edge);border-radius:8px;box-shadow:0 12px 30px #000a;z-index:50;padding:4px}+  .bo-ver:hover .bo-verdrop,.bo-ver:focus-within .bo-verdrop{display:block}+  .bo-veropt{display:block;padding:5px 9px;border-radius:5px;font-size:12px;font-weight:500;color:var(--ink);cursor:pointer;white-space:nowrap}+  .bo-veropt:hover{background:#243049}+  .bo-veropt.on{color:var(--acc)}+  .bo-verbanner{margin-top:8px;font-size:12px;color:#f0a81c;background:#2a2109;border:1px solid #4a380e;border-radius:6px;padding:5px 10px}+  .bo-verbanner a{color:var(--acc)}   .bo-3d-wrap{position:relative;margin:16px 20px;padding:0}   .bo-3d-wrap .bo-3d{margin:0 auto}   .bo-fs-abs{position:absolute;top:0;right:0;z-index:3}@@ -200,11 +235,17 @@ </style> </head> <body>+  <div id="app-loader">+    <img src="loader.gif" alt="Loading">+    <div class="lt">Adom <span>Project Manager</span></div>+    <div class="lp">Fetching Projects: <b id="app-loader-pct">0%</b></div>+  </div>   <aside id="list">     <h1>Adom <small>Project Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>     <div id="crumb"></div>     <div id="tabbar" class="tabs"></div>     <input id="q" placeholder="filter boards…">+    <div id="tagfilter" class="tagfilter"></div>     <div id="toolbar" style="display:none">       <label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>       <div class="tbtns">@@ -265,16 +306,32 @@ function updateToolbar(){   const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}   const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s)); }+// First-load splash: the animated adom mark + a "Fetching Projects: XX%" that eases+// toward 90% while the (single, blocking) list request is in flight, then snaps to 100%.+let _ldTimer=null;+function _setPct(p){ const b=document.getElementById('app-loader-pct'); if(b)b.textContent=Math.round(p)+'%'; }+function startLoader(){+  const el=document.getElementById('app-loader'); if(!el)return;+  el.classList.remove('hide'); let pct=0; _setPct(0);+  clearInterval(_ldTimer);+  _ldTimer=setInterval(()=>{ pct+=Math.max(0.7,(90-pct)*0.08); if(pct>90)pct=90; _setPct(pct); },80);+}+function finishLoader(){+  clearInterval(_ldTimer); _setPct(100);+  const el=document.getElementById('app-loader'); if(el)setTimeout(()=>el.classList.add('hide'),280);+} async function loadList(fresh){   const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');-  if(!fresh) rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>';+  if(!fresh){ startLoader(); rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>'; }   try{     const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());     all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);     fetch('api/env').then(r=>r.json()).then(e=>{ if(e&&e.os) env=e; }).catch(()=>{});     fetch('api/libraries').then(r=>r.json()).then(l=>{ if(l&&l.symLibs) userLibs=l; }).catch(()=>{});     render();+    if(!fresh) finishLoader();   }catch(e){+    if(!fresh) finishLoader();     rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';   }finally{ if(btn)btn.classList.remove('spin'); } }@@ -291,27 +348,55 @@ function renderTabs(){   bar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');   bar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; boardSlug?backToBoards():renderBoards(); }); }+// Tag filter chips (kind: molecule/board · EDA: kicad/altium/fusion). Faceted:+// AND across the two facets, OR within a facet. Empty = show everything.+let filterTags=new Set();+const FILTER_CHIPS=[['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];+function renderFilterBar(){+  const el=document.getElementById('tagfilter'); if(!el)return;+  el.style.display='';+  el.innerHTML=FILTER_CHIPS.map(([k,label])=>`<span class="fchip ${k}${filterTags.has(k)?' on':''}" data-t="${k}">${label}</span>`).join('');+  el.querySelectorAll('.fchip').forEach(ch=>ch.onclick=()=>{ const t=ch.dataset.t; filterTags.has(t)?filterTags.delete(t):filterTags.add(t); renderBoards(); });+}+function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }+function matchesFilter(c){+  if(!filterTags.size) return true;+  const kindSel=['molecule','board'].filter(t=>filterTags.has(t));+  const edaSel=['kicad','altium','fusion'].filter(t=>filterTags.has(t));+  if(kindSel.length && !kindSel.includes(boardKind(c))) return false;+  if(edaSel.length){ const eda=boardEdaOf(c); if(!eda||!edaSel.some(t=>eda.includes(t))) return false; }+  return true;+} function renderBoards(){   document.getElementById('toolbar').style.display='none';selected.clear();   document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';   const q=qval(),el=rowsEl();el.innerHTML='';-  renderTabs();+  renderTabs(); renderFilterBar();   const boards=all.filter(isBoard);   const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org);   const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org));   const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';-  if(boardTab==='public'){renderPublic(boards,el);return;}-  const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));+  if(boardTab==='public'){renderPublic(boards.filter(matchesFilter),el);return;}+  const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)).filter(matchesFilter);   if(!list.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML=boardTab==='mine'?'no personal projects yet — push a board tagged <b>board</b>/<b>molecule</b> (without --org), then press ↻':'no projects in your organization(s) yet';el.appendChild(d);return;}   if(boardTab==='mine'){     sortBoards(list).forEach(c=>el.appendChild(boardRow(c)));   } else {-    // group by organization (so multiple orgs list under their own headers)+    // group by organization; within each org, split into projects you contribute to+    // (you're the author) vs everyone else's.     orgs.forEach(o=>{-      const inOrg=sortBoards(list.filter(c=>c.org===o));+      const inOrg=list.filter(c=>c.org===o);       if(!inOrg.length)return;       const h=document.createElement('div');h.className='cathead';h.textContent=`${o} · ${inOrg.length}`;el.appendChild(h);-      inOrg.forEach(c=>el.appendChild(boardRow(c)));+      const yours=sortBoards(inOrg.filter(c=>c.owner&&c.owner===me));+      const others=sortBoards(inOrg.filter(c=>!c.owner||c.owner!==me));+      const section=(label,arr)=>{+        if(!arr.length)return;+        const sh=document.createElement('div');sh.className='subhead';sh.textContent=`${label} · ${arr.length}`;el.appendChild(sh);+        arr.forEach(c=>el.appendChild(boardRow(c)));+      };+      section('Your Projects',yours);+      section('Other Projects',others);     });   } }@@ -376,7 +461,10 @@ function boardRow(c){   // to the per-row fetch when it's absent.   const edaKnown=Array.isArray(c.eda);   const edaSpan=edaKnown?edaTags(c.eda):`<span class="tag eda" data-eda="${c.slug}"></span>`;-  d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}</div>`;+  const vis=(c.visibility||'').toLowerCase();+  const visClass=vis==='private'?'private':(vis==='public'||vis==='open')?'public':'';+  const visTag=vis?`<span class="tag vis ${visClass}">${vis==='open'?'public':vis}</span>`:'';+  d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}${c.owner?`<span class="auth">${c.owner}</span>`:''}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}${visTag}</div>`;   d.onclick=()=>openBoard(c.slug);   if(!edaKnown)loadEda(c.slug);   return d;@@ -402,7 +490,7 @@ function renderPublic(boards,el){   renderPub(); } async function openBoard(slug){-  boardSlug=slug; curDeps=[]; selected.clear();+  boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;   const title=(bySlug[slug]||{}).title||slug;   document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;   renderBoardOverview(slug);@@ -418,11 +506,13 @@ async function openBoard(slug){ // board (from a wiki GLB if present), and small KiCad schematic + PCB views. function renderBoardOverview(slug){   const c=bySlug[slug]||{slug};+  const _vis=(c.visibility||'').toLowerCase();+  const _visTag=_vis?`<span class="tag vis ${_vis==='private'?'private':(_vis==='public'||_vis==='open')?'public':''}">${_vis==='open'?'public':_vis}</span>`:'';   const view=document.getElementById('view');   view.classList.remove('split');   document.getElementById('component-view').innerHTML='';   document.getElementById('board-overview').innerHTML=`-    <div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind ${boardKind(c)}">${boardKind(c)}</span><span class="tag eda" data-eda="${slug}"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div>+    <div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind ${boardKind(c)}">${boardKind(c)}</span><span class="tag eda" data-eda="${slug}"></span>${_visTag}<span id="bo-version" class="bo-verwrap"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div><div id="bo-verbanner" class="bo-verbanner" style="display:none"></div></div>     <div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d"><div class="bo-na">loading 3D…</div></div></div></div>     <div class="bo-views">       <div class="bo-view"><div class="bo-view-hd"><h3>Schematic</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><select id="bo-sheet" class="bo-sheet boxsheet" style="display:none"></select><div class="bo-boxinner" id="bo-sch"><div class="bo-na">rendering…</div></div></div></div>@@ -493,20 +583,56 @@ async function loadBoardMedia(slug){     const na='<div class="bo-na">board type not supported</div>';     if(d3)d3.innerHTML=na;if(sch)sch.innerHTML=na;if(pcb)pcb.innerHTML=na;return;   }-  if(d3)d3.innerHTML=m.has_3d?`<model-viewer src="board-glb/${slug}" camera-controls auto-rotate shadow-intensity="1"></model-viewer>`:`<div class="bo-na">board 3D file not available</div>`;+  curBoardMedia=m; boardVersion=null;+  renderVersionPill(); updateVerBanner();+  await mountBoardAssets(slug);+}+// Version selector state: boardVersion = the selected PAST version (null = current).+let boardVersion=null, curBoardMedia=null;+function verQ(){ const m=curBoardMedia||{}; return (boardVersion && m.current && boardVersion!==m.current) ? '?v='+encodeURIComponent(boardVersion) : ''; }+// Mount the 3D / PCB / schematic views for the selected version. Re-called when the+// version pill changes — each asset fetch carries ?v=<version> for a past version.+async function mountBoardAssets(slug){+  const m=curBoardMedia||{}, q=verQ();+  const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');+  if(d3)d3.innerHTML=m.has_3d?`<model-viewer src="board-glb/${slug}${q}" camera-controls auto-rotate shadow-intensity="1"></model-viewer>`:`<div class="bo-na">board 3D file not available</div>`;   const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');   if(sch){     if(sheets.length){       if(sel){         sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join('');         sel.style.display=sheets.length>1?'':'none';-        sel.onchange=async()=>{ if(boardSlug!==slug)return; sch.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(sch, await fetch(`board-svg/${slug}/sch/${sel.value}`).then(r=>r.text()), {light:true}); };+        sel.onchange=async()=>{ if(boardSlug!==slug)return; sch.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(sch, await fetch(`board-svg/${slug}/sch/${sel.value}${verQ()}`).then(r=>r.text()), {light:true}); };       }-      mountSvg(sch, await fetch(`board-svg/${slug}/sch/0`).then(r=>r.text()), {light:true});+      sch.innerHTML='<div class="bo-na">loading…</div>';+      mountSvg(sch, await fetch(`board-svg/${slug}/sch/0${q}`).then(r=>r.text()), {light:true});     } else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }   }   if(boardSlug!==slug)return;-  if(pcb){ if(m.has_pcb){ mountSvg(pcb, await fetch('board-svg/'+slug+'/pcb').then(r=>r.text()), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+  if(pcb){ if(m.has_pcb){ pcb.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(pcb, await fetch(`board-svg/${slug}/pcb${q}`).then(r=>r.text()), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+}+function selectVersion(v){+  const m=curBoardMedia||{};+  boardVersion=(v==null||v===m.current)?null:v;+  renderVersionPill(); updateVerBanner(); mountBoardAssets(boardSlug);+}+function renderVersionPill(){+  const el=document.getElementById('bo-version'); if(!el)return;+  const m=curBoardMedia||{}, vers=Array.isArray(m.versions)?m.versions:[];+  const pkgVer=(bySlug[boardSlug]||{}).version;                      // wiki package version (always present)+  const cur=boardVersion||m.current||(vers[0]&&vers[0].version)||pkgVer;+  if(!cur){ el.innerHTML=''; return; }                              // no version info at all+  if(vers.length>1){                                                 // multiple archived → clickable dropdown+    el.innerHTML=`<span class="bo-ver" tabindex="0">v${cur} ▾<span class="bo-verdrop">${vers.map(v=>`<span class="bo-veropt${v.version===cur?' on':''}" onclick="event.stopPropagation();selectVersion('${v.version}')">v${v.version}${v.version===m.current?' · current':''}</span>`).join('')}</span></span>`;+  } else {                                                           // single/no archive → static version label+    el.innerHTML=`<span class="bo-ver static">v${cur}</span>`;+  }+}+function updateVerBanner(){+  const b=document.getElementById('bo-verbanner'), m=curBoardMedia||{}; if(!b)return;+  const off=boardVersion&&m.current&&boardVersion!==m.current;+  b.style.display=off?'':'none';+  if(off)b.innerHTML=`Viewing <b>v${boardVersion}</b> — not the current version. <a href="#" onclick="selectVersion(null);return false">Back to v${m.current}</a>`; } // Inject an SVG on a dark background: drop the full-page paper rect (so the dark // box shows through AND the content crops tight via getBBox), optionally invert@@ -534,12 +660,17 @@ function mountSvg(box,svgText,opt){       if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; });       else el.style.fill='#0d0f14';     });-    // A pour is a big FILLED path (huge 'd'); pads are tiny paths/circles and traces-    // are stroke-only (computed fill:none). Silk/edge fills are skipped by color so a-    // filled silk logo stays readable.-    const skip=new Set(['rgb(242, 237, 161)','rgb(208, 210, 205)','rgb(255, 255, 255)']); // silk, edge, white+    // Identify copper POURS/zones by SHAPE, not size: a poured zone is filled with+    // fill-rule:evenodd because it carries clearance cutouts around every pad/trace,+    // while pads and simple fills are nonzero. This is area-independent, so it catches+    // small zones the old `d.length ≥ 2000` heuristic missed. Traces are stroke-only+    // (fill:none). Silk/edge fills are evenodd too (letter holes), so they're skipped+    // BY COLOR — keeping a filled silk logo fully opaque no matter its size.+    const skip=new Set([+      'rgb(242, 237, 161)','rgb(255, 230, 0)','rgb(255, 229, 0)', // silk (kicad + altium yellow)+      'rgb(208, 210, 205)','rgb(255, 255, 255)']);                // edge, white     svg.querySelectorAll('path').forEach(el=>{-      if((el.getAttribute('d')||'').length < 2000) return;       // pads / short segments → opaque+      if((getComputedStyle(el).fillRule||'').toLowerCase()!=='evenodd') return; // only poured zones → dim; pads/simple fills opaque       const f=getComputedStyle(el).fill;                          // resolves fill inherited from a parent <g>       if(!f||f==='none'||f==='rgba(0, 0, 0, 0)'||skip.has(f)) return; // traces / edge / silk → opaque       el.style.fillOpacity=opt.dimFills;                          // copper pour → translucent@@ -587,6 +718,7 @@ function typeOf(c){ } function renderDeps(){   renderTabs();+  document.getElementById('tagfilter').style.display='none';   document.getElementById('q').placeholder='filter components…';   document.getElementById('toolbar').style.display='flex';   const q=qval(),el=rowsEl();el.innerHTML='';