← Commit history

v0.3.3: in-app folder browser replaces native dialog (fixes Windows Browse-files wedge; universal Win/mac/Linux; path textbox + copy-paste); Windows PowerShell via -EncodedCommand (folder pick + library scans no longer echo-not-run); non-blocking picker; concurrent server + serialized bridge calls; curl --fail fixes render-folder false-positive; render-warning bar; multi-EDA tags; fills-only PCB transparency; 3D cap/center + fullscreen fix; controls-in-box; activity chips; tag recolors

barrett-land ·b27f6db470 ·3mo ago ·parent a6c5016
2 files changed +334−67
src/main.rs+210−38
@@ -158,16 +158,23 @@ pub fn run(port: u16) -> Result<()> {     println!("OK: Adom Project Manager on http://localhost:{port}");     println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");     println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");+    // Handle each request on its own thread. Many endpoints block on the desktop+    // bridge (folder listing, library scans, renders); a single-threaded loop would+    // serialize them, so e.g. the folder browser could wait tens of seconds behind+    // the Add-Project library scan. All shared state is Mutex-guarded, so concurrent+    // handlers are safe.     for mut req in server.incoming_requests() {-        let method = req.method().clone();-        let url = req.url().to_string();-        let mut body = String::new();-        if matches!(method, Method::Post) {-            // /api/apply bodies are small JSON — cap far above any real payload.-            let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);-        }-        let resp = route(&method, &url, &body);-        let _ = req.respond(resp);+        std::thread::spawn(move || {+            let method = req.method().clone();+            let url = req.url().to_string();+            let mut body = String::new();+            if matches!(method, Method::Post) {+                // /api/apply bodies are small JSON — cap far above any real payload.+                let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);+            }+            let resp = route(&method, &url, &body);+            let _ = req.respond(resp);+        });     }     Ok(()) }@@ -180,6 +187,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         (Method::Get, "/api/env") => json_resp(&api_env().to_string()),         (Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),         (Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),+        (Method::Get, p) if p.starts_with("/api/pick-folder-result") => {+            let tok = url.split_once("?f=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();+            json_resp(&pick_folder_result(&tok).to_string())+        }         (Method::Get, p) if p.starts_with("/api/project-diff") => {             let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();             json_resp(&project_diff(&param("board"), &param("dest")).to_string())@@ -197,6 +208,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();             json_resp(&suggest_path(&prefix).to_string())         }+        (Method::Get, p) if p.starts_with("/api/list-dirs") => {+            let pp = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();+            json_resp(&list_dirs(&pp).to_string())+        }         (Method::Get, "/") => html(INDEX_HTML),         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear@@ -290,7 +305,11 @@ fn wiki_token() -> Option<String> { }  fn curl(url: &str) -> Option<Vec<u8>> {-    let mut args: Vec<String> = vec!["-s".into(), "-L".into(), "--max-time".into(), "12".into()];+    // `--fail`: on an HTTP error (e.g. a missing file's 404) the wiki returns a JSON+    // error body; without --fail curl hands us those bytes and callers mistake the+    // error object for real content (a 404 manifest read as a valid render/ folder).+    // With --fail curl exits non-zero and emits nothing, so we correctly return None.+    let mut args: Vec<String> = vec!["-s".into(), "-f".into(), "-L".into(), "--max-time".into(), "12".into()];     // authenticate wiki requests so private/org pages are included (issue #120)     if url.contains("wiki.adom.inc") {         if let Some(tok) = wiki_token() {@@ -360,7 +379,7 @@ fn list_components() -> Value {         });         if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }     }).collect();-    let eda_map: std::collections::HashMap<String, &'static str> = std::thread::scope(|sc| {+    let eda_map: std::collections::HashMap<String, Vec<&'static str>> = std::thread::scope(|sc| {         let handles: Vec<_> = board_slugs.iter().map(|s| {             let s = s.clone();             sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })@@ -466,33 +485,38 @@ fn board_deps(slug: &str) -> Value {  /// The board's EDA toolchain, inferred from the files on its page: /// "kicad" | "altium" | "fusion" | "".-fn board_eda(slug: &str) -> &'static str {+/// Every EDA toolchain whose files are present on the board — a board can carry+/// more than one (e.g. a KiCad PCB alongside a Fusion/Eagle `.brd` + `.lbr`), so+/// this returns ALL matches, in a stable order, not just the first.+fn board_eda(slug: &str) -> Vec<&'static str> {     let files = list_page_files(slug);     let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });+    let mut out = Vec::new();     if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {-        "kicad"-    } else if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {-        "altium"-    } else if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion"]) {-        "fusion"-    } else {-        ""+        out.push("kicad");+    }+    if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {+        out.push("altium");+    }+    if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion", ".lbr"]) {+        out.push("fusion");     }+    out } -/// `board_eda` cached across requests. The EDA type never changes for a given+/// `board_eda` cached across requests. The EDA set never changes for a given /// board within a session, and the raw call costs a wiki file-listing round-trip; /// caching turns the left-list's per-row EDA into a one-time cost.-static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, &'static str>>> = Mutex::new(None);-fn board_eda_cached(slug: &str) -> &'static str {+static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, Vec<&'static str>>>> = Mutex::new(None);+fn board_eda_cached(slug: &str) -> Vec<&'static str> {     {         let mut g = EDA_CACHE.lock().unwrap();-        if let Some(&e) = g.get_or_insert_with(Default::default).get(slug) {-            return e;+        if let Some(e) = g.get_or_insert_with(Default::default).get(slug) {+            return e.clone();         }     }     let e = board_eda(slug);-    EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e);+    EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e.clone());     e } @@ -1012,13 +1036,115 @@ fn urldecode(s: &str) -> String { /// or cancels (empty path = cancelled / no dialog available). fn pick_folder() -> Value {     let (os, _) = machine();-    let cmd = match os.as_str() {-        "windows" => "powershell -NoProfile -STA -Command \"Add-Type -AssemblyName System.Windows.Forms; $f=New-Object System.Windows.Forms.FolderBrowserDialog; if($f.ShowDialog() -eq 'OK'){[Console]::Out.Write($f.SelectedPath)}\"".to_string(),-        "macos" => "osascript -e 'POSIX path of (choose folder)' 2>/dev/null".to_string(),-        _ => "zenity --file-selection --directory 2>/dev/null".to_string(),+    let id = unique_id();+    // A native folder dialog is MODAL and blocks until the user picks/cancels. If we+    // ran it synchronously through the bridge, the bridge's shell_execute would wait+    // for it and WEDGE the whole AD command channel. So launch it DETACHED, have it+    // write the chosen path (or __CANCELLED__) to a temp file, and let the frontend+    // poll /api/pick-folder-result. shell_execute returns instantly; AD stays free.+    // The token is just the id; each side rebuilds the temp path itself (on Windows+    // inside PowerShell via GetTempPath, since cmd mangles %TEMP%/forward-slash paths).+    let launch = match os.as_str() {+        "windows" => {+            // inner: the TopMost dialog (owner form pulls it to the foreground), writing the result+            let inner = format!(+                "$ProgressPreference='SilentlyContinue'\n\+                 Add-Type -AssemblyName System.Windows.Forms\n\+                 $o=New-Object System.Windows.Forms.Form\n\+                 $o.TopMost=$true;$o.ShowInTaskbar=$false;$o.Opacity=0\n\+                 $o.Show();$o.Activate()\n\+                 $d=New-Object System.Windows.Forms.FolderBrowserDialog\n\+                 $d.Description='Select the project folder'\n\+                 $r=$d.ShowDialog($o)\n\+                 $o.Close()\n\+                 $out=if($r -eq [System.Windows.Forms.DialogResult]::OK){{$d.SelectedPath}}else{{'__CANCELLED__'}}\n\+                 $p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'\n\+                 Set-Content -LiteralPath $p -Value $out -NoNewline -Encoding UTF8");+            let inner_b64 = b64(&utf16le(&inner));+            // outer: fire the inner off detached, then exit immediately so shell_execute+            // returns. The inner carries -STA (needed for the dialog); the OUTER must NOT+            // (an -STA outer adds ~7s of startup here). Base64-encoded so cmd/PS don't echo it.+            let outer = format!("Start-Process -WindowStyle Hidden powershell -ArgumentList @('-NoProfile','-STA','-EncodedCommand','{inner_b64}')");+            format!("powershell -NoProfile -EncodedCommand {}", b64(&utf16le(&outer)))+        }+        "macos" => format!("(osascript -e 'POSIX path of (choose folder)' > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),+        _ => format!("(zenity --file-selection --directory > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),+    };+    let _ = ad_shell_out(&launch);+    json!({ "token": id })+}++/// GET /api/pick-folder-result?f=<id> — poll the detached folder dialog: no file+/// yet = still open (`pending`); `__CANCELLED__`/empty = cancelled; else the path.+/// Reads (and deletes) the temp file so a stale result can't linger.+fn pick_folder_result(token: &str) -> Value {+    // the token is our own generated id — keep only its safe characters+    let id: String = token.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();+    if id.is_empty() {+        return json!({ "cancelled": true });+    }+    let (os, _) = machine();+    let content = if os == "windows" {+        // read+delete entirely inside PowerShell (cmd can't read the forward-slash %TEMP% path)+        ad_run(&ps_command(&format!(+            "$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'; \+             if(Test-Path -LiteralPath $p){{[Console]::Out.Write((Get-Content -LiteralPath $p -Raw)); Remove-Item -LiteralPath $p -Force}}else{{[Console]::Out.Write('__PENDING__')}}")))+    } else {+        let tmp = format!("/tmp/apm_pick_{id}.txt");+        let c = ad_run(&format!("cat '{tmp}' 2>/dev/null"));+        if c.is_some() {+            let _ = ad_shell_out(&format!("rm -f '{tmp}'"));+        }+        c.or_else(|| Some("__PENDING__".into()))     };-    let path = ad_run(&cmd).unwrap_or_default().trim().to_string();-    json!({ "path": path, "cancelled": path.is_empty() })+    match content.as_deref().map(str::trim) {+        None | Some("__PENDING__") => json!({ "pending": true }),+        Some("") | Some("__CANCELLED__") => json!({ "cancelled": true }),+        Some(path) => json!({ "path": path }),+    }+}++/// GET /api/list-dirs?p=<abspath> — the sub-folders of a directory on the user's+/// machine (each with its full path) plus the parent, powering the in-app folder+/// browser. Empty p starts at the home directory. This replaces the native OS+/// folder dialog (which can't render from a detached bridge process and can't be+/// run modally without wedging the bridge), and — unlike a browser file input — it+/// yields the real absolute path, which delivery needs.+fn list_dirs(path: &str) -> Value {+    let (os, home) = machine();+    let base = if path.trim().is_empty() { home } else { path.to_string() };+    if os == "windows" {+        let esc = base.replace('\'', "''");+        let script = format!(+            "$ErrorActionPreference='SilentlyContinue'; \+             $i=Get-Item -LiteralPath '{esc}'; \+             if(-not $i){{[Console]::Out.Write('@@ERR@@'); return}}; \+             $full=$i.FullName; $par=''; if($i.Parent){{$par=$i.Parent.FullName}}; \+             $names=Get-ChildItem -LiteralPath $full -Directory -Force | ForEach-Object {{ $_.Name + [char]9 + $_.FullName }} | Sort-Object; \+             [Console]::Out.Write($full + [char]10 + $par + [char]10 + '@@' + [char]10 + ($names -join [char]10))");+        let out = ad_run(&ps_command(&script)).unwrap_or_default().replace('\r', "");+        if out.is_empty() || out.contains("@@ERR@@") {+            return json!({ "path": base, "parent": "", "dirs": [] });+        }+        let (head, rest) = out.split_once("\n@@\n").unwrap_or((out.as_str(), ""));+        let mut hl = head.splitn(2, '\n');+        let full = hl.next().unwrap_or(&base).to_string();+        let par = hl.next().unwrap_or("").to_string();+        let dirs: Vec<Value> = rest.split('\n').filter(|l| !l.trim().is_empty())+            .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))+            .collect();+        json!({ "path": full, "parent": par, "dirs": dirs })+    } else {+        let q = sh_quote(&base);+        let full = ad_run(&format!("cd {q} 2>/dev/null && pwd")).unwrap_or_else(|| base.clone());+        let fq = sh_quote(&full);+        let par = ad_run(&format!("dirname {fq}")).filter(|p| *p != full).unwrap_or_default();+        let out = ad_run(&format!("for d in {fq}/*/ ; do [ -d \"$d\" ] && printf '%s\\t%s\\n' \"$(basename \"$d\")\" \"${{d%/}}\" ; done 2>/dev/null")).unwrap_or_default();+        let dirs: Vec<Value> = out.lines()+            .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))+            .collect();+        json!({ "path": full, "parent": par, "dirs": dirs })+    } }  /// GET /api/suggest-path?p=<prefix> — existing directories on the user's machine@@ -1188,7 +1314,13 @@ fn ad_shell(cmd: &str) {  /// Run a shell command on the user's machine and return its captured reply /// (whole adom-desktop stdout); None if the bridge call itself failed.+// The desktop bridge (AD) processes shell commands one at a time. Firing several+// at once (now that the HTTP server is multi-threaded) makes concurrent filesystem+// searches thrash the disk and each balloon 5-10x. Serialize them here so every+// bridge call stays fast; the server still handles local/cached requests in parallel.+static BRIDGE_LOCK: Mutex<()> = Mutex::new(()); fn ad_shell_out(cmd: &str) -> Option<String> {+    let _guard = BRIDGE_LOCK.lock().unwrap();     let args = json!({"command": cmd}).to_string();     let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;     if !out.status.success() {@@ -1197,6 +1329,41 @@ fn ad_shell_out(cmd: &str) -> Option<String> {     Some(String::from_utf8_lossy(&out.stdout).to_string()) } +/// Base64 (standard alphabet, padded).+fn b64(data: &[u8]) -> String {+    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+    for c in data.chunks(3) {+        let (b0, b1, b2) = (c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0));+        out.push(T[(b0 >> 2) as usize] as char);+        out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);+        out.push(if c.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });+        out.push(if c.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });+    }+    out+}++fn utf16le(s: &str) -> Vec<u8> {+    s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()+}++/// Wrap a PowerShell script as a `-EncodedCommand` invocation. The AD Windows+/// shell is cmd; passing `powershell -Command "…"` lets the double quotes survive+/// to PowerShell, which then treats the script as a string literal and ECHOES it+/// instead of running it. Base64 UTF-16LE has no quotes/spaces to mangle, so the+/// script runs verbatim regardless of the intervening cmd/JSON quoting.+fn ps_command(script: &str) -> String {+    format!("powershell -NoProfile -STA -EncodedCommand {}", b64(&utf16le(script)))+}++/// A process-unique id (millis + counter) for temp filenames.+fn unique_id() -> String {+    static C: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+    let n = C.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);+    format!("{t}_{n}")+}+ /// Board media rendered by the hosted service-kicad container (KiCad 10) and /// cached per board. Tuple: (schematic sheets `[(name, svg)]`, PCB svg, board GLB). type BoardMedia = (Vec<(String, String)>, Option<String>, Option<Vec<u8>>);@@ -1375,7 +1542,10 @@ fn render_board_media(slug: &str) -> BoardMedia { /// Manager serves them directly — no service-kicad, no wait — instead of /// rendering live. Regenerated on every board publish (`--render-board`). fn stored_render(slug: &str) -> Option<Value> {-    wiki_bytes(slug, "render/manifest.json").and_then(|b| serde_json::from_slice::<Value>(&b).ok())+    wiki_bytes(slug, "render/manifest.json")+        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+        // must actually look like a manifest, not e.g. a 404 error object+        .filter(|m| m.get("v").is_some() || m.get("glb").is_some() || m.get("sheets").is_some()) }  /// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns@@ -1388,18 +1558,20 @@ fn board_media(slug: &str) -> Value {         let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())             .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())             .unwrap_or_default();-        return json!({"rendering": false, "is_kicad": true, "stored": true, "sheets": sheets,+        return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": 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)});     }+    // no stored render/ folder from here on — the board falls back to slow live+    // rendering (has_render:false), which the UI flags as an error state.     if let Some((sheets, pcb, glb)) = cached_board(slug) {         let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();-        return json!({"rendering": false, "is_kicad": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});+        return json!({"rendering": false, "is_kicad": true, "has_render": false, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});     }     // only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered     let is_kicad = list_page_files(slug).iter().any(|n| n.ends_with(".kicad_pcb") || n.ends_with(".kicad_sch"));     if !is_kicad {-        return json!({"rendering": false, "is_kicad": false, "sheets": [], "has_3d": false, "has_pcb": false});+        return json!({"rendering": false, "is_kicad": false, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false});     }     let start = {         let mut g = BOARD_RENDERING.lock().unwrap();@@ -1416,7 +1588,7 @@ fn board_media(slug: &str) -> Value {             }         });     }-    json!({"rendering": true, "is_kicad": true, "sheets": [], "has_3d": false, "has_pcb": false})+    json!({"rendering": true, "is_kicad": true, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false}) }  /// Does a file exist on the user's machine? None = couldn't determine@@ -1583,7 +1755,7 @@ fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String,     let (os, _) = machine();     let out = if os == "windows" {         let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");-        ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '\\(symbol \\\"([^\\\"]+)\\\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}\""))+        ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '\\(symbol \"([^\"]+)\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}")))     } else {         let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");         ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))@@ -1620,7 +1792,7 @@ fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::     let pat = pats.join("|");     let out = if os == "windows" {         let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");-        ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}\""))+        ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}")))     } else {         let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");         ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))
src/manage.html+124−29
@@ -67,6 +67,22 @@   #modal-dest .destrow{display:flex;gap:8px;align-items:center}   #modal-dest .browsebtn{flex:2;text-align:left;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px ui-monospace,monospace;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}   #modal-dest .browsebtn:hover{border-color:var(--acc)}+  .fb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}+  .fb-panel{width:min(560px,92vw);max-height:80vh;background:#12151c;border:1px solid var(--edge);border-radius:12px;display:flex;flex-direction:column;overflow:hidden}+  .fb-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--edge)}+  .fb-title{font-weight:600}+  .fb-x{background:none;border:none;color:var(--mut);font-size:16px;cursor:pointer;line-height:1}+  .fb-path{display:block;width:100%;box-sizing:border-box;padding:9px 16px;font:12px ui-monospace,monospace;color:var(--acc);background:#0d0f14;border:none;border-bottom:1px solid var(--edge);outline:none}+  .fb-path:focus{border-bottom-color:var(--acc)}+  .fb-list{overflow-y:auto;flex:1;min-height:48px;max-height:52vh;padding:6px 0}+  .fb-row:last-child{border-bottom:none}+  .fb-row{padding:9px 16px;cursor:pointer;font-size:13px;border-bottom:1px solid #171b22;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+  .fb-row:hover{background:#1e222b}+  .fb-up{color:var(--acc)}+  .fb-empty,.fb-loading{padding:16px;color:var(--mut);font-size:13px;text-align:center}+  .fb-foot{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--edge)}+  .fb-foot button{border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;border:1px solid var(--edge);background:#0d0f14;color:var(--ink)}+  .fb-pick{background:var(--acc)!important;border-color:var(--acc)!important;color:#04121f!important;font-weight:600}   #modal-dest #destname{flex:1}   #modal-dest .destsep{color:var(--mut);font:14px ui-monospace,monospace;flex:0 0 auto}   .libsel{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;max-width:100%}@@ -108,19 +124,27 @@   #view.split #component-view{flex:0 0 50%;display:block;border-top:3px solid var(--acc)}   #hint{color:var(--mut);text-align:center;margin-top:20vh}   /* board overview (top of the right pane) */+  .render-warn{background:#3a1214;border-bottom:1px solid #7f1d1d;color:#fca5a5;padding:11px 20px;font-size:13px;line-height:1.45;font-weight:500}+  .render-warn b{color:#fecaca}   .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-3d-wrap{position:relative;margin:16px 20px;padding:0}-  .bo-3d-wrap .bo-3d{margin:0}+  .bo-3d-wrap .bo-3d{margin:0 auto}   .bo-fs-abs{position:absolute;top:0;right:0;z-index:3}   .bo-3d-wrap .bo-fs-abs{top:10px;right:10px}-  .bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto}-  .bo-3d{aspect-ratio:1;margin:16px 20px;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}+  .bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto;max-width:none;width:auto;margin:0;align-self:stretch}+  /* cap the 3D at a comfortable size and center it (with side gutters) instead of+     stretching to the full panel width on wide screens */+  .bo-3d{position:relative;aspect-ratio:1;max-width:560px;margin:16px auto;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}+  /* controls live INSIDE each view box: fullscreen at top-left, sheet dropdown at top-right */+  .bo-boxinner{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden}+  .boxfs{position:absolute;top:8px;left:8px;z-index:4}+  .boxsheet{position:absolute;top:8px;right:8px;z-index:4;max-width:55%}   .bo-3d model-viewer{width:100%;height:100%;aspect-ratio:auto}   .bo-views{display:flex;gap:14px;padding:0 20px 20px}   .bo-view{flex:1;min-width:0;position:relative}-  .bo-view-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;min-height:24px;padding-right:34px}+  .bo-view-hd{display:flex;align-items:center;gap:8px;margin-bottom:6px;min-height:24px}   .bo-view h3{margin:0;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}   .bo-sheet{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;max-width:62%}   .bo-sheet:focus{outline:none;border-color:var(--acc)}@@ -294,10 +318,53 @@ function renderBoards(){ const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'}; function boardKind(c){return (c.tags||[]).map(x=>(''+x).toLowerCase()).includes('molecule')?'molecule':'board';} let edaCache={};+// a board can carry more than one toolchain (e.g. KiCad + Fusion) — render one tag each.+function edaTags(list){return (list||[]).map(e=>`<span class="tag eda ${e}">${EDA_LABEL[e]||''}</span>`).join('');} function loadEda(slug){-  const fill=eda=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.textContent=EDA_LABEL[eda]||'';el.className='tag eda '+(eda||'');});+  const fill=list=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.outerHTML=edaTags(list);});   if(edaCache[slug]!==undefined){fill(edaCache[slug]);return;}-  fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||'';fill(edaCache[slug]);}).catch(()=>{});+  fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||[];fill(edaCache[slug]);}).catch(()=>{});+}+// In-app folder browser: navigates the user's filesystem via the bridge and calls+// onPick(absolutePath) with the real path when the user selects a folder.+function openFolderBrowser(start,onPick){+  let cur=start||'';+  const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');+  const ov=document.createElement('div'); ov.className='fb-overlay';+  ov.innerHTML=`<div class="fb-panel">+    <div class="fb-head"><span class="fb-title">Choose a folder</span><button class="fb-x" title="cancel">✕</button></div>+    <input class="fb-path" id="fb-path" spellcheck="false" autocomplete="off" placeholder="type or paste a path, press Enter to go">+    <div class="fb-list" id="fb-list"><div class="fb-loading">loading…</div></div>+    <div class="fb-foot"><button class="fb-cancel">Cancel</button><button class="fb-pick">Select this folder</button></div>+  </div>`;+  document.body.appendChild(ov);+  const close=()=>ov.remove();+  ov.querySelector('.fb-x').onclick=close;+  ov.querySelector('.fb-cancel').onclick=close;+  ov.querySelector('.fb-pick').onclick=()=>{ if(cur){onPick(cur);} close(); };+  ov.onclick=e=>{ if(e.target===ov)close(); };+  const listEl=ov.querySelector('#fb-list'), pathEl=ov.querySelector('#fb-path');+  // type/paste a path + Enter to jump straight there+  pathEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); load(pathEl.value.trim()); } });+  async function load(p){+    listEl.innerHTML='<div class="fb-loading">loading…</div>';+    let d;+    try{+      // use the listing prefetched when the modal opened, so the first open is instant+      // even while the modal's library scan is still running on the bridge+      if((!p||p==='') && fbPrefetch){ d=await fbPrefetch; fbPrefetch=null; }+      if(!d) d=await fetch('api/list-dirs?p='+encodeURIComponent(p||'')).then(r=>r.json());+    }+    catch(e){ listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }+    cur=d.path||p||''; pathEl.value=cur;+    let html='';+    if(d.parent) html+=`<div class="fb-row fb-up" data-p="${esc(d.parent)}">⬆ ..</div>`;+    (d.dirs||[]).forEach(x=>{ html+=`<div class="fb-row" data-p="${esc(x.path)}">📁 ${esc(x.name)}</div>`; });+    if(!(d.dirs||[]).length) html+='<div class="fb-empty">(no sub-folders)</div>';+    listEl.innerHTML=html;+    listEl.querySelectorAll('.fb-row').forEach(r=>r.onclick=()=>load(r.dataset.p));+  }+  load(cur); } function boardRow(c){   const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;@@ -305,8 +372,8 @@ function boardRow(c){   const kind=boardKind(c);   // eda arrives with the list (computed server-side in parallel); only fall back   // to the per-row fetch when it's absent.-  const edaKnown=c.eda!==undefined;-  const edaSpan=edaKnown?`<span class="tag eda ${c.eda}">${EDA_LABEL[c.eda]||''}</span>`:`<span class="tag eda" data-eda="${c.slug}"></span>`;+  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>`;   d.onclick=()=>openBoard(c.slug);   if(!edaKnown)loadEda(c.slug);@@ -354,10 +421,10 @@ function renderBoardOverview(slug){   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-view bo-3d-wrap"><div class="bo-3d" id="bo3d"><div class="bo-na">loading 3D…</div></div><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button></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"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>Schematic</h3><select id="bo-sheet" class="bo-sheet" style="display:none"></select></div><div class="bo-viewbody" id="bo-sch"><div class="bo-na">rendering…</div></div></div>-      <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody" id="bo-pcb"><div class="bo-na">rendering…</div></div></div>+      <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>+      <div class="bo-view"><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo-pcb"><div class="bo-na">rendering…</div></div></div></div>     </div>     <div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`;   loadBoardMedia(slug);@@ -391,6 +458,21 @@ function fillBoardInfo(slug,log){     const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');   }).catch(()=>{}); }+// Red error bar at the top when a board/molecule has no stored render/ folder — it+// can't load properly (falls back to slow live rendering) until it's re-published.+function showRenderWarning(slug,m){+  if(boardSlug!==slug)return;+  const ov=document.getElementById('board-overview'); if(!ov)return;+  let bar=ov.querySelector('.render-warn');+  const missing=m&&m.is_kicad!==false&&m.has_render===false;+  if(!missing){ if(bar)bar.remove(); return; }+  const kind=boardKind(bySlug[slug]||{});+  if(!bar){+    bar=document.createElement('div'); bar.className='render-warn';+    ov.insertBefore(bar,ov.firstChild);+  }+  bar.innerHTML=`⚠ This ${kind} is missing a render folder, so its 3D, PCB and schematic may not load properly. Reupload the ${kind} with the <b>publish-board</b> skill to regenerate the render assets.`;+} async function loadBoardMedia(slug){   // service-kicad renders in the background (a board GLB can take ~a minute) —   // poll until it's done, keeping the "rendering…" placeholders meanwhile.@@ -398,9 +480,11 @@ async function loadBoardMedia(slug){   for(let i=0;i<60 && m.rendering;i++){     try{ m=await fetch('api/board-media/'+slug).then(r=>r.json()); }catch(e){ m={}; break; }     if(boardSlug!==slug)return; // navigated away+    showRenderWarning(slug,m); // flag a missing render/ folder as soon as we know     if(m.rendering)await new Promise(r=>setTimeout(r,2000));   }   if(boardSlug!==slug)return;+  showRenderWarning(slug,m);   const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');   if(m.is_kicad===false){     const na='<div class="bo-na">board type not supported</div>';@@ -435,21 +519,27 @@ function mountSvg(box,svgText,opt){   svg.removeAttribute('width');svg.removeAttribute('height');   svg.style.width='100%';svg.style.height='100%';svg.style.display='block';   if(opt.invert)svg.style.filter='invert(1) hue-rotate(180deg)';-  // dim the copper fills so lower layers show through (see all 4 copper layers);-  // keep silkscreen/edge fully opaque so they stay readable+  // dim ONLY the copper zone pours (the large filled ground/power planes) so lower+  // layers show through; pads, vias, traces and silk stay fully opaque.   if(opt.dimFills){-    const keep=new Set(['#f2eda1','#d0d2cd']); // silk + edge stay opaque (markings); ALL copper dims+    // drill holes first (unchanged): via (<0.5mm) → gold, larger holes → background     svg.querySelectorAll('[style*="fill"]').forEach(el=>{       const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/);-      if(!m)return;-      const c=m[1].toLowerCase();-      if(c==='#ffffff'){-        // drill holes: diameter < 0.5mm = via → gold; larger (TH pads, mounting holes) → background.-        // ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)-        const circles=el.querySelectorAll('[r]');-        if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; });-        else el.style.fill='#0d0f14';-      } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim all copper (top/bottom/inner)+      if(!m||m[1].toLowerCase()!=='#ffffff')return;+      // ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)+      const circles=el.querySelectorAll('[r]');+      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+    svg.querySelectorAll('path').forEach(el=>{+      if((el.getAttribute('d')||'').length < 2000) return;       // pads / short segments → 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     });   }   // remove the full-page paper rect (crop to the content on the box background)@@ -673,7 +763,11 @@ function openAddLib(){   modalGo=()=>doApply(curPlan,'kicad',false);   showModal(); }+let fbPrefetch=null; function openAddProj(){+  // prefetch the home folder listing FIRST (before the modal's library scan hogs the+  // bridge) so clicking "Browse files" opens the folder browser instantly+  fbPrefetch=fetch('api/list-dirs?p=').then(r=>r.json()).catch(()=>null);   const slugs=curDeps.slice();   const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_');   let mode='personal';@@ -700,12 +794,13 @@ function openAddProj(){   };   // run the check for the current mode (drives its status bar + coloring)   const runCheck=()=>{ if(mode==='project'){ inLib={}; checkProjectDiff(boardSlug,projPath(),draw); } else { projDiff={}; checkInLib(slugs,draw); } };-  browse.onclick=async()=>{ const old=browse.dataset.path||'Browse files'; browse.textContent='Opening…'; browse.disabled=true;-    try{ const r=await fetch('api/pick-folder').then(r=>r.json());-      if(r.path){ chosenDir=r.path; browse.dataset.path=r.path; browse.textContent=r.path; browse.title=r.path; if(mode==='project')runCheck(); }-      else browse.textContent=old;-    }catch(e){ browse.textContent=old; }-    browse.disabled=false; };+  // An in-app folder browser (native OS dialogs can't render from the detached+  // bridge process, and a browser file input can't yield the absolute path delivery+  // needs). Navigates the user's filesystem over the bridge; the path is real.+  browse.onclick=()=>openFolderBrowser(browse.dataset.path||'', path=>{+    chosenDir=path; browse.dataset.path=path; browse.textContent=path; browse.title=path;+    if(mode==='project')runCheck();+  });   selOpt('mode','mode',v=>{mode=v;runCheck();});   document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';   inLib={}; projDiff={}; draw(); runCheck(); showModal();