← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-project-manager"-version = "0.1.0"+version = "0.10.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+2−2
@@ -1,8 +1,8 @@ [package] name = "adom-project-manager"-version = "0.1.0"+version = "0.10.0" edition = "2021"-description = "adom Project Manager — browse your wiki boards/projects, view symbol + footprint + 3D and the footprint layer stackup, add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr."+description = "Adom Project Manager — the central hub for navigating wiki boards, molecules, and libraries into Hydrogen: browse your wiki boards/projects, view symbol + footprint + 3D and the footprint layer stackup, add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr."  [[bin]] name = "adom-project-manager"
README.md+54−7
@@ -1,11 +1,58 @@-# Adom Project Manager → renamed to **Adom Nucleus**+# Adom Project Manager -`adom-project-manager` is now **`adom-nucleus`**.+A small web app (served locally, opened in a Hydrogen webview) for browsing and+working with your Adom wiki **boards / projects**. Built on+[`adom-lbr`](https://wiki.adom.inc/adom/adom-lbr) — it reuses the adom-lbr codec+to render and convert symbols/footprints on the fly. -- Install: `adompkg install adom/adom-nucleus`-- Run: `adom-nucleus --port 8785 &`+![Adom Project Manager — projects](docs/projects.png) -Kept as a deprecated alias (depends on `adom/adom-nucleus`). Please migrate.+## What it does -![Adom Nucleus](docs/hero.png)-![Adom Nucleus](docs/hero.png)+- **Projects, three tabs**+  - **Personal** — boards you own that aren't scoped to an org.+  - **Organization** — boards scoped to an org you belong to, grouped by org.+  - **Public** — a search box; type a name + Enter to find any public board.+  - Boards you've starred on the wiki pin to the top (★); the rest sort alphabetically.+- **Component viewer** — click a board to see its components (categorized by type),+  then click one to view its **symbol + footprint + 3D**, rendered on the fly from+  the canonical adom-lbr JSON, with pin/pad highlight.+- **Interactive board & schematic views** — the board pane is+  **adom-2dboard** and the schematic pane is **adom-schematic**,+  embedded live rather than as flat images. Click a pad or trace to highlight its+  whole net; hover a symbol or pad for MPN, stock, price, wiki page and datasheet.+  Both render from the project's own EDA export, so they look exactly like KiCad.+  ![Interactive board view with a net highlighted](docs/board-view.png)+- **Layer stackup** — for a footprint, see which layer each graphic becomes in+  **KiCad** and **Altium**.+- **Add to Library** — push selected components into your existing KiCad/Altium+  libraries.+- **Add Project to my Computer** — deliver a board's full KiCad project. In+  *project-only* mode it's made **self-contained**: symbols/footprints are+  extracted into a project library, every `lib_id`/footprint reference is+  re-pointed to it, and project-local `sym-lib-table` / `fp-lib-table` are written+  — so it opens anywhere with no broken links.++## Run++```bash+adom-project-manager --port 8785+```++Then open `http://localhost:<port>` in a browser or Hydrogen webview. Delivery to+your machine (Add to Library / Add Project) goes through the **adom-desktop**+bridge, and identity/stars come from the signed-in `adom-wiki` session.++## Built on adom-lbr++The Rust binary depends on the `altium-codec` crate from `adom-lbr` (KiCad+import/export + the canonical part model). As a wiki package it declares+`adom/adom-lbr` as a dependency.++## Publishing boards++Boards are published to the wiki with **[molecule-publish](https://wiki.adom.inc/adom/molecule-publish)**,+which installs alongside the Manager as a dependency. It bundles 3D models,+normalizes paths and renders the board so the page has everything the Manager+needs (3D, schematic, PCB). The older bundled `publish-board` skill is+deprecated and is no longer shipped.
SKILL.md+38−3
@@ -1,9 +1,44 @@ --- name: adom-project-manager-description: DEPRECATED alias — renamed to Adom Nucleus (adom-nucleus). Use adom-nucleus.+description: Open the Adom Project Manager to browse your Adom wiki boards/projects (Personal / Organization / Public), view a component's symbol + footprint + 3D and per-EDA layer stackup, add components to your KiCad/Altium libraries, or deliver a self-contained KiCad project to your computer. Triggers include "open the project manager", "browse my boards", "add this board's parts to my kicad library", "download this project as a self-contained kicad project", "view the layer stackup". user-invocable: true --- -# Adom Project Manager (deprecated)+# Adom Project Manager -Renamed to **Adom Nucleus**. Install `adom/adom-nucleus`.+A local web app (opened in a Hydrogen webview) for browsing and working with your+Adom wiki boards/projects. Built on `adom-lbr` (it reuses the codec to render ++convert on the fly).++## Run it++```bash+adom-project-manager --port 8785 &+```++Open `http://localhost:8785` in a browser or Hydrogen webview. To surface it in+the user's webview panel, use `adom-cli hydrogen webview open-or-refresh`.++## What's inside++- **Projects tabs** — Personal (your non-org boards), Organization (org boards you+  can access, grouped by org), Public (search box, Enter to load). Starred boards+  pin to the top; the rest are alphabetical.+- **Viewer** — click a board → its components (categorized) → symbol + footprint ++  3D + the KiCad/Altium layer stackup, with pin/pad highlight.+- **Interactive board/schematic panes** — embedded **adom-2dboard**+  (`/board-view/<slug>/pcb`) and **adom-schematic** (`/board-view/<slug>/sch`).+  Net highlighting, per-part hover with live stock/price. The Manager serves+  `/board-view/<slug>/enrich` on their behalf, since an embed has no server of+  its own. Rendered views are cached in-process per slug+version.+- **Add to Library** — selected components into the user's KiCad/Altium libraries.+- **Add Project to my Computer** — a board's full KiCad project; *project-only*+  mode makes it self-contained (project symbol/footprint libraries, re-pointed+  references, project-local lib-tables).++## Prerequisites++- **adom-desktop** bridge running — delivery to the user's machine (Add to+  Library / Add Project, project fetch) and desktop screenshots go through it.+- Signed-in **adom-wiki** session — identity (Personal/Org tabs) and stars.+- **python3** in the container — the self-containment step for Add Project.
build.rsadded+22
@@ -0,0 +1,22 @@+// Single-source the app version from package.json — the version the wiki actually+// publishes (`adom-wiki pkg publish` reads package.json). Historically the app reported+// env!("CARGO_PKG_VERSION") from Cargo.toml, which release.sh never kept in lockstep with+// package.json, so the update check compared a stale Cargo.toml version against the newer+// published version and showed a permanent phantom "Update available". Deriving the+// version from package.json at build time makes that class of bug impossible: the binary's+// reported version is always exactly the published version.+use std::fs;++fn main() {+    println!("cargo:rerun-if-changed=package.json");+    let ver = fs::read_to_string("package.json")+        .ok()+        .and_then(|s| {+            s.split("\"version\"")+                .nth(1)+                .and_then(|r| r.split('"').nth(1))+                .map(str::to_string)+        })+        .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());+    println!("cargo:rustc-env=APP_VERSION={ver}");+}
docs/board-view.png
⋯ 1 unchanged line ⋯
docs/projects.png
⋯ 1 unchanged line ⋯
install.sh+24−2
@@ -1,4 +1,26 @@ #!/bin/bash set -euo pipefail-echo "adom-project-manager was RENAMED to adom-nucleus (installed via the adom/adom-nucleus dependency)."-B="${HOME}/.local/bin"; [ -x "$B/adom-nucleus" ] && ln -sf "$B/adom-nucleus" "$B/adom-project-manager" 2>/dev/null || true+DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"+install -m 0755 "$DIR/bin/adom-project-manager" "$BIN/adom-project-manager"; echo "Installed adom-project-manager"+# Reverted from adom-nucleus: drop that command so upgraders don't keep a stale binary.+rm -f "$BIN/adom-nucleus"++# Board-GLB optimizer: --render-board shrinks the raw kicad-cli board GLB (merge+# primitives, quantize) before publishing. It shells to `node <script>` using+# @gltf-transform; both are OPTIONAL — without them --render-board just publishes+# the un-optimized GLB. Install the script + its deps best-effort next to the bin.+if [ -f "$DIR/glb_optimize.mjs" ]; then+  install -m 0644 "$DIR/glb_optimize.mjs" "$BIN/glb_optimize.mjs"+  if command -v npm >/dev/null 2>&1; then+    ( cd "$BIN" && npm install --no-save --no-audit --no-fund \+        @gltf-transform/core @gltf-transform/functions @gltf-transform/extensions >/dev/null 2>&1 ) \+      && echo "Installed board-GLB optimizer (gltf-transform)" \+      || echo "Note: gltf-transform deps not installed; --render-board will publish un-optimized GLBs"+  else+    echo "Note: npm not found; --render-board will publish un-optimized GLBs (install node+npm to enable optimization)"+  fi+fi++# 0.5.0 bundled the deprecated publish-board skill; molecule-publish replaces it+# (installed as a package dependency), so clear the stale copy.+rm -rf "${HOME}/.claude/skills/publish-board"
package.json+78−10
@@ -1,13 +1,81 @@ {-  "slug": "adom-project-manager", "version": "0.7.1", "type": "app",-  "description": "DEPRECATED — renamed to Adom Nucleus (adom/adom-nucleus). Kept installable so existing installs and references keep working; depends on adom-nucleus. Install adom/adom-nucleus going forward.",-  "dependencies": { "adom/adom-nucleus": "^0.7.0" },-  "tags": ["deprecated","alias","nucleus","eda","kicad"],-  "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+  "slug": "adom-project-manager",+  "version": "0.10.0",+  "type": "app",+  "description": "Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manager / adom-project-manager.)",+  "dependencies": {+    "adom/adom-lbr": "^2.6.0",+    "adom/adom-symbol": "^1.1.8",+    "adom/adom-footprint": "^1.0.21",+    "adom/adom-2dboard": "^0.4.0",+    "adom/adom-schematic": "^0.5.0",+    "adom/molecule-publish": "^2.1.2",+    "adom/kicad-library-manager": "^1.1.1",+    "adom/chip-fetcher-lite": "^1.1.1"+  },+  "tags": [+    "kicad",+    "altium",+    "eda",+    "project-manager",+    "hub",+    "project",+    "library",+    "footprint",+    "symbol",+    "wiki",+    "adom-lbr",+    "schematic",+    "pcb",+    "viewer",+    "interactive"+  ],+  "hero": {+    "type": "image",+    "path": "docs/hero.png"+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },   "needs_sudo": false,-  "files": ["SKILL.md","README.md","install.sh","uninstall.sh","docs/**"],-  "hero": { "type":"billboard","headline":"Renamed to Adom Nucleus","subhead":"adom-project-manager is now adom/adom-nucleus — the central hub for boards, molecules and libraries. This alias keeps the old name installable and pulls Nucleus.","screenshot":"docs/hero.png","accent":"#00b8b1" },-  "discovery_triggers": ["adom project manager","adom nucleus"],-  "discovery_pitch": "Renamed to Adom Nucleus (adom/adom-nucleus).",-  "sample_prompts": [ { "prompt": "Open Adom Nucleus", "label": "Open Nucleus" } ]+  "files": [+    "bin/**",+    "SKILL.md",+    "README.md",+    "install.sh",+    "uninstall.sh",+    "glb_optimize.mjs",+    "docs/**"+  ],+  "discovery_triggers": [+    "adom project manager",+    "browse wiki boards",+    "view footprint layer stackup",+    "add components to my kicad library",+    "deliver a self-contained kicad project",+    "project manager for eda",+    "open the interactive board view",+    "highlight a net on this board",+    "what is this part on the schematic"+  ],+  "discovery_pitch": "The central hub for your hardware: browse your wiki boards/projects, inspect each component's symbol, footprint, 3D model and per-EDA layer stackup, cross-probe the board and schematic, add parts to your KiCad/Altium libraries, and deliver self-contained KiCad projects into Hydrogen — built on adom-lbr.",+  "sample_prompts": [+    {+      "prompt": "Open Adom Project Manager",+      "label": "Open Project Manager"+    },+    {+      "prompt": "Add this board's components to my KiCad libraries",+      "label": "Add to library"+    },+    {+      "prompt": "Deliver this project as a self-contained KiCad project",+      "label": "Add project"+    },+    {+      "prompt": "Open the interactive PCB view for this board and highlight the +3.3V net",+      "label": "Explore board"+    }+  ] }
release.shadded+87
@@ -0,0 +1,87 @@+#!/bin/bash+#+# Canonical release.sh for an adompkg source repo.+#+# Reads version from package.json, optionally bumps it (--bump patch|minor|major),+# runs build.sh if present, then runs `adompkg publish` and prints the resulting+# package URL.+#+# Usage:+#   ADOMPKG_TOKEN=<carbon-api-key> ./release.sh                # publish current version+#   ADOMPKG_TOKEN=<carbon-api-key> ./release.sh --bump patch   # bump + publish+#+set -euo pipefail++DIR="$(cd "$(dirname "$0")" && pwd)"+cd "$DIR"++if [ ! -f package.json ]; then+  echo "release.sh: no package.json found in $DIR" >&2+  exit 1+fi++REGISTRY="${ADOMPKG_REGISTRY:-https://git-wiki-ktqxite5iglh.adom.cloud}"++if [ -z "${ADOMPKG_TOKEN:-}" ]; then+  if [ -r /var/run/adom/api-key ]; then+    ADOMPKG_TOKEN="$(cat /var/run/adom/api-key)"+    export ADOMPKG_TOKEN+  else+    echo "release.sh: ADOMPKG_TOKEN is required (set ADOMPKG_TOKEN=<carbon-api-key>)" >&2+    exit 1+  fi+fi++# Read current version.+CUR_VERSION=$(node -e "console.log(require('./package.json').version)")+SLUG=$(node -e "console.log(require('./package.json').slug || require('./package.json').name)")++BUMP=""+for a in "$@"; do+  case "$a" in+    --bump)+      shift+      BUMP="${1:-patch}"+      shift || true+      ;;+    --bump=*)+      BUMP="${a#--bump=}"+      shift+      ;;+  esac+done++if [ -n "$BUMP" ]; then+  NEXT=$(node -e "+    const [maj, min, pat] = process.argv[1].split('.').map(n => parseInt(n, 10));+    const b = process.argv[2];+    let out;+    if (b === 'major') out = (maj + 1) + '.0.0';+    else if (b === 'minor') out = maj + '.' + (min + 1) + '.0';+    else out = maj + '.' + min + '.' + (pat + 1);+    console.log(out);+  " "$CUR_VERSION" "$BUMP")+  echo "release.sh: bumping $CUR_VERSION -> $NEXT ($BUMP)"+  node -e "+    const fs = require('fs');+    const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));+    p.version = process.argv[1];+    fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');+  " "$NEXT"+  CUR_VERSION="$NEXT"+fi++# Optional build step.+if [ -x ./build.sh ]; then+  echo "release.sh: running build.sh"+  ./build.sh+fi++# Publish.+echo "release.sh: publishing $SLUG@$CUR_VERSION to $REGISTRY"+adompkg publish . --org adom --public --yes++# Print published URL.+echo+echo "Published: $REGISTRY/pages/$SLUG"+echo "Install:   adompkg install $SLUG"
src/main.rs+1051−513
@@ -1,4 +1,4 @@-//! Adom Project Manager — a standalone web app (tiny_http → Hydrogen webview),+//! Adom Project Manager, a standalone web app (tiny_http → Hydrogen webview), //! built on the `adom-lbr` codec. Lists your wiki boards/projects (Personal / //! Organization / Public), and on click opens a viewer (symbol + footprint + 3D, //! plus the footprint LAYER STACKUP showing what layer each graphic becomes in@@ -48,12 +48,6 @@ fn main() { fn render_and_upload(slug: &str) -> bool {     eprintln!("rendering {slug} …");     let (sheets, pcb_svg, glb) = render_board_media(slug);-    // The board GLB from service-kicad is a raw kicad-cli export: glTF-standard-    // Y-up and carrying no up-axis stamp (issue #218). Optimize it (quantize +-    // the +90°X Z-up rotation) and stamp `adomMolecule.upAxis = "z"`, so BOTH the-    // wiki 3D viewer (assumes Z-up) and APM (keys on the stamp) render it flat —-    // no hand-patching, no consumer inferring orientation from geometry.-    let glb = glb.map(|g| finalize_board_glb(&g));     if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {         eprintln!("nothing rendered (not a KiCad board?)");         return false;@@ -93,31 +87,35 @@ fn render_and_upload(slug: &str) -> bool {         .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());-    // Read the existing manifest as our MERGE BASE (issue #218 problem 2). We only-    // overwrite the keys the pipeline owns and preserve everything else — top-level-    // keys a downstream tool added (`viewers`, `viewers_path`, …) and unknown keys-    // on each versions[] entry. A wholesale rewrite silently destroyed those; a-    // 404/error body that isn't really a manifest falls back to an empty base.-    let existing: serde_json::Map<String, Value> = wiki_bytes(slug, "render/manifest.json")+    // 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.as_object().cloned())-        .filter(|m| m.contains_key("v") || m.contains_key("glb") || m.contains_key("versions"))+        .and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())         .unwrap_or_default();-    // Did the render change vs the last archived version? (drives version archival)-    let latest_hash = existing.get("versions").and_then(|a| a.as_array())-        .and_then(|a| a.first()).and_then(|v| v.get("hash")).and_then(|h| h.as_str())-        .unwrap_or("");+    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>/+        // 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); }-    }-    let manifest = merge_render_manifest(&existing, &version, &new_hash, changed,-        glb.is_some(), pcb_svg.is_some(), &sheet_names);+        // 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": 2,+        "current": current,+        "glb": glb.is_some(),+        "pcb": pcb_svg.is_some(),+        "sheets": sheet_names,+        "versions": versions,+    });     let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());      // Push TARGETED paths (not the whole render/ dir) so previously-archived@@ -142,11 +140,11 @@ fn render_and_upload(slug: &str) -> bool { }  const WIKI: &str = "https://wiki.adom.inc";-// Hosted headless KiCad 10 (service-kicad) — renders board SVG/GLB server-side,+// Hosted headless KiCad 10 (service-kicad), renders board SVG/GLB server-side, // so board views need no local kicad-cli and no desktop bridge. const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud"; -/// The wiki component list is slow (~8s) but stable — fetch it once, serve+/// The wiki component list is slow (~8s) but stable, fetch it once, serve /// instantly after. First click blocks; the rest are snappy. static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None); @@ -198,7 +196,7 @@ fn whoami() -> (String, Vec<String>) { }  // Per-user "starred" comes straight from the wiki page data (pending the wiki API-// adding a per-user `starred` field — tracked in adom/wiki issues). Starred boards+// adding a per-user `starred` field, tracked in adom/wiki issues). Starred boards // pin to the top of each list; there is no star toggle in this app (you star on // the wiki). Until the field lands, `starred` is simply absent → false for all. @@ -223,7 +221,7 @@ pub fn run(port: u16) -> Result<()> {             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.+                // /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);@@ -237,9 +235,55 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {     let path = url.split('?').next().unwrap_or(url);     match (method, path) {         (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),+        // toast channel: the AI narrates over the running UI with `POST /ui/toast`+        // ({message,type}); the frontend drains the queue via `GET /ui/toasts`.+        (Method::Post, "/ui/toast") => {+            let v: serde_json::Value = serde_json::from_str(body).unwrap_or(json!({}));+            let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();+            let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("info").to_string();+            if !msg.is_empty() { toast_queue().lock().unwrap().push(json!({"message": msg, "type": ty})); }+            json_resp("{\"ok\":true}")+        }+        (Method::Get, "/ui/toasts") => {+            let drained: Vec<serde_json::Value> = std::mem::take(&mut *toast_queue().lock().unwrap());+            json_resp(&json!({"toasts": drained}).to_string())+        }+        // ── embedded Tools (chip-fetcher-lite, kicad-library-manager): Project Manager+        // launches each Node tool on demand and reverse-proxies it under+        // /tools/<slug>/ so it renders in an iframe on Project Manager's own origin (the+        // tools use document.baseURI-relative URLs, so a base-path proxy just works). ──+        (Method::Post, p) if p.starts_with("/tools/launch/") => {+            let slug = &p["/tools/launch/".len()..];+            match ensure_tool(slug) {+                Some(_) => json_resp(&json!({"ok": true, "base": format!("tools/{slug}/")}).to_string()),+                None => Response::from_string(json!({"ok": false, "error": "could not start tool"}).to_string()).with_status_code(502).with_header(hdr("application/json")),+            }+        }+        (m, p) if p.starts_with("/tools/") => {+            let after = &p["/tools/".len()..];+            let (slug, rest) = after.split_once('/').unwrap_or((after, ""));+            let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");+            proxy_tool(slug, rest, query, m, body)+        }         (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),         (Method::Get, "/api/env") => json_resp(&api_env().to_string()),         (Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),+        // the name each part's symbol will carry in the user's library, so the UI can+        // show (and let them edit) the real default instead of a guess+        (Method::Get, p) if p.starts_with("/api/symbol-names") => {+            let slugs: Vec<String> = url.split_once("?slugs=")+                .map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q)))+                .unwrap_or_default()+                .split(',').filter(|s| !s.is_empty()).map(String::from).collect();+            let map: std::collections::HashMap<String, String> = slugs.iter()+                .filter_map(|s| load_part(s, &up(s)).ok().map(|(p, _)| (s.clone(), p.symbol.name.clone())))+                .collect();+            json_resp(&json!(map).to_string())+        }+        (Method::Get, p) if p.starts_with("/api/create-library") => {+            let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();+            json_resp(&create_library(&param("kind"), &param("name")).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();@@ -267,6 +311,11 @@ 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, "/pm/version") => Response::from_string(+            format!("{{\"version\":\"{}\"}}", env!("APP_VERSION"))).with_header(hdr("application/json")),+        // Update check: compares the running binary's version against the latest+        // published on the wiki (cached ~10min) so the UI can flag an available update.+        (Method::Get, "/api/update") => json_resp(&update_status().to_string()),         (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()),@@ -342,6 +391,11 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {                 None => Response::from_string("no hero").with_status_code(404),             }         }+        // The canonical Babylon adom-3d-viewer, hosted BY Project Manager (same-origin) so PRIVATE+        // boards render: /viewer3d serves a page that loads the bundle from /v3d/ (a proxy of+        // the wiki's PUBLIC static bundle) and the model from board-glb (server-authenticated).+        (Method::Get, "/viewer3d") => html(VIEWER3D_HTML),+        (Method::Get, p) if p.starts_with("/v3d/") => v3d_asset(&p["/v3d/".len()..]),         (Method::Get, p) if p.starts_with("/org-avatar/") => {             // Proxy an organization's real avatar. Org avatars live in Carbon/Hydrogen; the             // public Carbon API (carbon.adom.inc/orgs/<org>) returns an avatar_url on the@@ -359,25 +413,12 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         }         (Method::Get, p) if p.starts_with("/board-svg/") => {             // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>  [?v=<version>]-            //-            // The slug itself CONTAINS a '/' when it is owner-qualified (`adom/usb3-…`,-            // which is every org board), so parse the view selector off the END. Splitting-            // from the front took `adom` as the whole slug and 404'd both panes.             let (path, ver) = split_ver(url);-            let rest = &path["/board-svg/".len()..];-            let (slug, kind, sch_idx): (&str, &str, usize) = if let Some(s) = rest.strip_suffix("/pcb") {-                (s, "pcb", 0)-            } else if let Some((head, i)) = rest.rsplit_once('/') {-                match (head.strip_suffix("/sch"), i.parse::<usize>().ok()) {-                    (Some(s), Some(n)) => (s, "sch", n),-                    _ => ("", "", 0),-                }-            } else {-                ("", "", 0)-            };-            let sub = |dir: &str| match kind {-                "pcb" => Some(format!("{dir}pcb.svg")),-                "sch" => Some(format!("{dir}sheet-{sch_idx:03}.svg")),+            let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();+            let slug = segs.first().copied().unwrap_or("");+            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;@@ -385,9 +426,9 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             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 kind {-                    "pcb" => pcb,-                    "sch" => sheets.get(sch_idx).map(|(_, s)| s.clone()),+                .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(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));@@ -396,31 +437,26 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {                 None => Response::from_string("not rendered").with_status_code(404),             }         }-        (Method::Get, p) if p.starts_with("/board-viewer/") => {-            // forms: /board-viewer/<slug>/<name>  [?v=<version>]-            // Serves the self-contained INTERACTIVE viewer HTML (adom-layout-viewer /-            // adom-schematic-viewer `embed` output): real net highlighting, layer-            // toggles incl. inner copper, component hover. These are archived per board-            // version under viewers/<version>/, mirroring render/<version>/.-            //-            // With ?v= we deliberately do NOT fall back to the current viewer: a board-            // version that predates the viewer archive must show its own static SVG-            // rather than silently render a DIFFERENT revision of the board.+        // /board-view/<slug>/enrich?mpn=&lcsc=, stock/price/MPN lookup on behalf of an+        // EMBEDDED viewer (an embed is a static HTML blob with no server of its own, and+        // its fetch("enrich") resolves here). Delegates to the viewer's own `enrich` verb.+        (Method::Get, p) if p.starts_with("/board-view/") && p.contains("/enrich") => {+            Response::from_string(board_view_enrich(url)).with_header(hdr("application/json"))+        }+        (Method::Get, p) if p.starts_with("/board-view/") => {+            // Interactive PCB / schematic view via adom-2dboard / adom-schematic.+            // forms: /board-view/<slug>/pcb  or  /board-view/<slug>/sch  [?v=<version>]             let (path, ver) = split_ver(url);-            let rest = &path["/board-viewer/".len()..];-            let (slug, name) = match rest.rsplit_once('/') { Some(x) => x, None => ("", "") };-            let safe = !name.is_empty()-                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');-            let html = if !safe || slug.is_empty() {-                None-            } else if let Some(v) = ver.as_ref() {-                wiki_bytes(slug, &format!("viewers/{v}/{name}.html"))-            } else {-                wiki_bytes(slug, &format!("viewers/{name}.html"))-            };-            match html {-                Some(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),-                None => Response::from_string("no viewer").with_status_code(404),+            let segs: Vec<&str> = path["/board-view/".len()..].splitn(2, '/').collect();+            let slug = segs.first().copied().unwrap_or("");+            let kind = match segs.get(1).copied() { Some("pcb") => "pcb", Some("sch") => "sch", _ => "" };+            // ?sheet=<file>: render a specific hierarchical child sheet instead of the root+            let sheet = url.split_once('?').and_then(|(_, q)| q.split('&')+                .find_map(|kv| kv.strip_prefix("sheet="))).map(|s| pct(s))+                .filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/') && s.ends_with(".kicad_sch"));+            match render_board_view_sheet(slug, kind, ver.as_deref(), sheet.as_deref()) {+                Some(h) => Response::from_string(h).with_header(hdr("text/html; charset=utf-8")),+                None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:24px'>interactive view unavailable (no KiCad file on this page)</p>").with_header(hdr("text/html; charset=utf-8")).with_status_code(404),             }         }         (Method::Get, p) if p.starts_with("/board-glb/") => {@@ -452,11 +488,11 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { }  // ----------------------------------------------------------------------------// Wiki access (via curl — no HTTP-client dep)+// Wiki access (via curl, no HTTP-client dep) // ---------------------------------------------------------------------------  /// The wiki API token (same source the adom-wiki CLI uses), cached. Without it,-/// unauthenticated requests return only PUBLIC pages — so private/org component+/// unauthenticated requests return only PUBLIC pages, so private/org component /// pages would never be listed. Read once; None if unavailable. static WIKI_TOKEN: Mutex<Option<Option<String>>> = Mutex::new(None); fn wiki_token() -> Option<String> {@@ -499,14 +535,64 @@ fn wiki_text(slug: &str, file: &str) -> Option<String> {     curl(&url).map(|b| String::from_utf8_lossy(&b).to_string()) } fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {-    curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}"))+    curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{}", pct_enc_path(file)))+}++/// Percent-encode a wiki file PATH so names with spaces (e.g. `PM COIL Molecule.brd`)+/// or other unsafe chars fetch correctly. Preserves `/` (files live in subdirs like+/// `render/manifest.json`) and the RFC-3986 unreserved set; encodes everything else.+fn pct_enc_path(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    for b in s.bytes() {+        match b {+            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),+            _ => out.push_str(&format!("%{:02X}", b)),+        }+    }+    out+}++/// `a > b` for dotted numeric versions ("0.8.7" > "0.8.6"), tolerant of pre-release+/// suffixes and missing components (treated as 0).+fn semver_gt(a: &str, b: &str) -> bool {+    let parse = |s: &str| -> (u64, u64, u64) {+        let mut it = s.trim().trim_start_matches('v').split('.').map(|x| {+            x.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("0").parse::<u64>().unwrap_or(0)+        });+        (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))+    };+    parse(a) > parse(b) } -/// The page's model_3d GLB — the exact 3D file the wiki page displays. Drew's+/// Running version vs the latest published on the wiki (dist-tags.latest), cached for+/// ~10 minutes so the header badge doesn't hit the registry on every poll.+static UPDATE_CACHE: Mutex<Option<(std::time::Instant, Value)>> = Mutex::new(None);+fn update_status() -> Value {+    if let Some((t, v)) = UPDATE_CACHE.lock().unwrap().as_ref() {+        if t.elapsed().as_secs() < 600 { return v.clone(); }+    }+    let current = env!("APP_VERSION");+    let latest = curl(&format!("{WIKI}/api/packages/adom/adom-project-manager/dist-tags"))+        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+        .and_then(|v| dig(&v, "dist_tags.latest").and_then(|s| s.as_str()).map(String::from));+    let update = latest.as_deref().map(|l| semver_gt(l, current)).unwrap_or(false);+    let v = json!({+        "current": current,+        "latest": latest,+        "update": update,+        "url": format!("{WIKI}/adom/adom-project-manager"),+    });+    *UPDATE_CACHE.lock().unwrap() = Some((std::time::Instant::now(), v.clone()));+    v+}++/// 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>> {+    // 1) An explicit model_3d_path in package.json (e.g. mp2393 → its world-baked+    //    <slug>.glb). This is the author's deliberate override, so it wins.     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()) {@@ -517,6 +603,21 @@ fn page_model_glb(slug: &str) -> Option<Vec<u8>> {             }         }     }+    // 2) The CURRENT render GLB (render/<current>/board.glb). The render pipeline points the+    //    PAGE's model_3d_path here and it's what the wiki 3D viewer loads — correctly+    //    oriented. The top-level <slug>.glb / render/board.glb can be a stale, differently+    //    oriented export (uv-current-monitor-board showed SIDEWAYS because we served that+    //    instead of render/1.0.0/board.glb). Resolve <current> from the render manifest.+    if let Some(cur) = wiki_bytes(slug, "render/manifest.json")+        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+        .and_then(|m| m.get("current").and_then(|c| c.as_str()).map(String::from))+        .filter(|s| !s.is_empty())+    {+        if let Some(b) = wiki_bytes(slug, &format!("render/{cur}/board.glb")) {+            return Some(b);+        }+    }+    // 3) The <slug>.glb convention (last resort).     wiki_bytes(slug, &format!("{slug}.glb")) } @@ -538,192 +639,6 @@ fn glb_up_axis(bytes: &[u8]) -> Option<String> {         .get("upAxis")?.as_str().map(|s| s.to_lowercase()) } -/// Build the render manifest MERGE-SAFELY (issue #218 problem 2). Starts from the-/// existing manifest so keys the pipeline does not own survive — unknown top-level-/// keys (`viewers`, `viewers_path`, …) a downstream tool added, and unknown keys on-/// each versions[] entry — overwriting only `v`/`current`/`glb`/`pcb`/`sheets`/-/// `versions[].{version,hash,glb,pcb,sheets}`. When `changed`, the current version's-/// entry is MERGED onto any prior entry for that version (so its extra keys survive)-/// and re-prepended, marking it the newest.-fn merge_render_manifest(-    existing: &serde_json::Map<String, Value>,-    version: &str, new_hash: &str, changed: bool,-    has_glb: bool, has_pcb: bool, sheet_names: &[String],-) -> Value {-    let mut versions: Vec<Value> = existing.get("versions")-        .and_then(|a| a.as_array()).cloned().unwrap_or_default();-    if changed {-        let mut entry = versions.iter()-            .find(|v| v.get("version").and_then(|x| x.as_str()) == Some(version))-            .and_then(|v| v.as_object().cloned())-            .unwrap_or_default();-        entry.insert("version".into(), json!(version));-        entry.insert("hash".into(), json!(new_hash));-        entry.insert("glb".into(), json!(has_glb));-        entry.insert("pcb".into(), json!(has_pcb));-        entry.insert("sheets".into(), json!(sheet_names));-        versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version));-        versions.insert(0, Value::Object(entry));-    }-    let current = versions.first()-        .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))-        .unwrap_or_else(|| version.to_string());-    let mut manifest = existing.clone();-    manifest.insert("v".into(), json!(2));-    manifest.insert("current".into(), json!(current));-    manifest.insert("glb".into(), json!(has_glb));-    manifest.insert("pcb".into(), json!(has_pcb));-    manifest.insert("sheets".into(), json!(sheet_names));-    manifest.insert("versions".into(), json!(versions));-    Value::Object(manifest)-}--/// Make a raw kicad-cli board GLB safe for every consumer (issue #218 problem 1):-/// optimize it if the gltf-transform helper is installed (quantize + bake-friendly-/// merges, and its own +90°X Z-up rotation), then guarantee it is Z-up AND carries-/// `asset.extras.adomMolecule.upAxis = "z"`. Never fails — falls back to the input-/// bytes so a missing `node`/optimizer only costs size, never correctness.-fn finalize_board_glb(glb: &[u8]) -> Vec<u8> {-    let optimized = optimize_glb(glb).unwrap_or_else(|| glb.to_vec());-    ensure_zup_stamp(&optimized)-}--/// Locate the installed `glb_optimize.mjs` (shipped beside the binary by install.sh,-/// or in ~/.local/bin) and run it: `node glb_optimize.mjs <in> <out>`. Returns the-/// optimized bytes, or None if node/script/deps are unavailable or it fails.-fn optimize_glb(glb: &[u8]) -> Option<Vec<u8>> {-    let script = std::env::current_exe().ok()-        .and_then(|p| p.parent().map(|d| d.join("glb_optimize.mjs")))-        .filter(|p| p.exists())-        .or_else(|| {-            let home = std::env::var("HOME").ok()?;-            let p = std::path::PathBuf::from(home).join(".local/bin/glb_optimize.mjs");-            p.exists().then_some(p)-        })?;-    let _ = std::fs::create_dir_all(SCRATCH);-    let id = unique_id();-    let inp = format!("{SCRATCH}/opt-{id}.in.glb");-    let outp = format!("{SCRATCH}/opt-{id}.out.glb");-    std::fs::write(&inp, glb).ok()?;-    let ok = Command::new("node").arg(&script).arg(&inp).arg(&outp)-        .status().map(|s| s.success()).unwrap_or(false);-    let out = if ok { std::fs::read(&outp).ok() } else { None };-    let _ = std::fs::remove_file(&inp);-    let _ = std::fs::remove_file(&outp);-    out.filter(|b| b.len() > 20 && &b[0..4] == b"glTF")-}--/// Guarantee a binary GLB is Z-up and stamped `adomMolecule.upAxis = "z"`.-/// Idempotent: if the stamp is already present the bytes are returned unchanged. If-/// the content is rotated (an `adom_zup` node from the optimizer) but unstamped, only-/// the stamp is added. Otherwise every scene root is reparented under a new node-/// rotated +90° about X (maps +Y → +Z) and the stamp is written. On any parse-/// failure the input is returned untouched. Only the JSON chunk is rewritten; the-/// BIN chunk (and any trailing chunks) are preserved verbatim.-fn ensure_zup_stamp(glb: &[u8]) -> Vec<u8> {-    let orig = glb.to_vec();-    if glb.len() < 20 || &glb[0..4] != b"glTF" {-        return orig;-    }-    let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;-    let json_start = 20usize;-    let json_end = match json_start.checked_add(json_len) {-        Some(e) if e <= glb.len() => e,-        _ => return orig,-    };-    // chunk 0 type must be JSON (0x4E4F534A)-    if u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]) != 0x4E4F_534A {-        return orig;-    }-    let mut j: Value = match serde_json::from_slice(&glb[json_start..json_end]) {-        Ok(v) => v,-        Err(_) => return orig,-    };-    let already_stamped = j.get("asset").and_then(|a| a.get("extras"))-        .and_then(|e| e.get("adomMolecule")).and_then(|m| m.get("upAxis"))-        .and_then(|u| u.as_str()).map(|s| s.eq_ignore_ascii_case("z")).unwrap_or(false);-    if already_stamped {-        return orig;-    }-    let obj = match j.as_object_mut() {-        Some(o) => o,-        None => return orig,-    };-    // Rotate to Z-up unless already rotated (optimizer left an `adom_zup` node).-    let has_zup = obj.get("nodes").and_then(|n| n.as_array())-        .map(|a| a.iter().any(|n| n.get("name").and_then(|s| s.as_str()) == Some("adom_zup")))-        .unwrap_or(false);-    if !has_zup {-        // Plan the reparenting from an immutable read (one +90°X wrapper per scene),-        // then apply it — never holding two mutable sub-borrows of `obj` at once.-        let scenes_roots = j_scenes_roots(obj);-        let mut nodes_len = obj.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);-        let mut new_nodes: Vec<Value> = Vec::new();-        let mut scene_updates: Vec<(usize, usize)> = Vec::new();-        for (scene_idx, roots) in scenes_roots {-            if roots.is_empty() { continue; }-            let new_idx = nodes_len;-            new_nodes.push(json!({-                "name": "adom_zup",-                "rotation": [0.707_106_78_f64, 0.0, 0.0, 0.707_106_78_f64],-                "children": roots,-            }));-            scene_updates.push((scene_idx, new_idx));-            nodes_len += 1;-        }-        if !new_nodes.is_empty() {-            if let Some(nodes) = obj.entry("nodes").or_insert_with(|| json!([])).as_array_mut() {-                nodes.extend(new_nodes);-            }-            if let Some(scenes) = obj.get_mut("scenes").and_then(|s| s.as_array_mut()) {-                for (si, ni) in scene_updates {-                    if let Some(sc) = scenes.get_mut(si).and_then(|s| s.as_object_mut()) {-                        sc.insert("nodes".into(), json!([ni]));-                    }-                }-            }-        }-    }-    // stamp asset.extras.adomMolecule.upAxis = "z"-    let asset = obj.entry("asset").or_insert_with(|| json!({}));-    let extras = asset.as_object_mut().unwrap().entry("extras").or_insert_with(|| json!({}));-    let am = extras.as_object_mut().unwrap().entry("adomMolecule").or_insert_with(|| json!({}));-    if let Some(m) = am.as_object_mut() { m.insert("upAxis".into(), json!("z")); }--    rebuild_glb(glb, &j).unwrap_or(orig)-}--/// (scene_index, root node-index list) for every scene, read before mutation.-fn j_scenes_roots(obj: &serde_json::Map<String, Value>) -> Vec<(usize, Vec<Value>)> {-    obj.get("scenes").and_then(|s| s.as_array()).map(|scenes| {-        scenes.iter().enumerate().map(|(i, sc)| {-            let roots = sc.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();-            (i, roots)-        }).collect()-    }).unwrap_or_default()-}--/// Re-emit a binary GLB with a replaced JSON chunk, preserving all following chunks-/// (BIN etc.) verbatim. JSON is padded with spaces, binary chunks with zeros, each to-/// a 4-byte boundary — per the glTF binary spec.-fn rebuild_glb(orig: &[u8], json: &Value) -> Option<Vec<u8>> {-    let old_json_len = u32::from_le_bytes([orig[12], orig[13], orig[14], orig[15]]) as usize;-    let after_json = 20usize.checked_add(old_json_len)?;-    let trailing = orig.get(after_json..).unwrap_or(&[]);-    let mut js = serde_json::to_vec(json).ok()?;-    while js.len() % 4 != 0 { js.push(b' '); }-    let total = 12 + 8 + js.len() + trailing.len();-    let mut out = Vec::with_capacity(total);-    out.extend_from_slice(b"glTF");-    out.extend_from_slice(&2u32.to_le_bytes());-    out.extend_from_slice(&(total as u32).to_le_bytes());-    out.extend_from_slice(&(js.len() as u32).to_le_bytes());-    out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes()); // "JSON"-    out.extend_from_slice(&js);-    out.extend_from_slice(trailing);-    Some(out)-}- /// 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>) {@@ -745,7 +660,7 @@ fn up(slug: &str) -> String { fn list_components() -> Value {     let mut items = Vec::new();     // The wiki caps each list response at ~200 rows regardless of `limit`, and there-    // are now hundreds of components — a single fetch drops most of them (boards+    // are now hundreds of components, a single fetch drops most of them (boards     // included). Page through with `offset` until we've collected every component.     let mut offset = 0usize;     loop {@@ -780,7 +695,13 @@ fn list_components() -> Value {             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();             let score = pg.get("composite_score").and_then(|s| s.as_f64()).unwrap_or(0.0);-            items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version, "score": score}));+            // "Recent" sort in the gallery keys on when the board was last pushed+            // (last_commit_at), falling back to the page's updated/created stamp.+            let updated = pg.get("last_commit_at").and_then(|s| s.as_str())+                .or_else(|| pg.get("updated_at").and_then(|s| s.as_str()))+                .or_else(|| pg.get("created_at").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, "score": score, "updated": updated}));         }         offset += n;         let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;@@ -799,7 +720,7 @@ fn list_components() -> Value {     // otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).     let board_slugs: Vec<String> = items.iter().filter_map(|it| {         let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {-            a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule"))+            a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule" | "circuit"))         });         if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }     }).collect();@@ -839,7 +760,7 @@ fn part_embed(slug: &str, kind: &str) -> Option<String> {     }     let mpn = up(slug);     let (part, _) = load_part(slug, &mpn).ok()?;-    let dir = std::env::temp_dir().join(format!("apm-embed-{slug}"));+    let dir = std::env::temp_dir().join(format!("pm-embed-{slug}"));     std::fs::create_dir_all(&dir).ok()?;     let (text, ext, tool) = match kind {         "symbol" => (export_kicad_symbol(&part.symbol), "kicad_sym", "adom-symbol"),@@ -862,7 +783,7 @@ fn part_embed(slug: &str, kind: &str) -> Option<String> {     // (<aside>) AND the "adom-symbol/footprint" top bar so the viewer gets the whole card;     // both reappear only when the card is fullscreened (iframe grows past the breakpoint).     // Also shrink the top-left zoom controls (their default 30px is chunky in a small card).-    let fit = "<style id=\"apm-fit\">\+    let fit = "<style id=\"pm-fit\">\         @media(max-width:639px){aside{display:none!important}header{display:none!important}}\         .tools,.av-tools{gap:2px!important;padding:2px!important;left:8px!important;top:8px!important}\         .tools button,.av-tools button{width:22px!important;height:22px!important}\@@ -906,7 +827,7 @@ fn get_part(slug: &str) -> Value {     }) } -/// A board/molecule's component dependencies — the other wiki components it's+/// A board/molecule's component dependencies, the other wiki components it's /// built from. Read from the board's **`package.json` `dependencies`** (the same /// npm-style manifest the wiki dependency graph is built from, so the two agree). /// Values may be `{slug: ver}`, `[slug,…]`, or `[{slug|ref|name}, …]`; owner@@ -935,16 +856,62 @@ fn deps_of(slug: &str) -> Vec<String> { }  /// Components on a board. Architecture: a board depends on a component LIBRARY,-/// and the library depends on the components. Resolve one hop — any dependency+/// and the library depends on the components. Resolve one hop, any dependency /// that itself has dependencies is a library and is expanded to its components; /// a dependency with none is treated as a direct component (backward-compatible /// with boards that still list components directly).+/// A circuit's bill of materials, derived from the `adom-block.json` on its page+/// (the neutral design-block model). Aggregates the block's component instances by+/// (value, lcsc, footprint) with a quantity and the reference designators. A part+/// links to a wiki component page only when one is resolved (`page`); otherwise it's+/// an informative BOM line. This is how `circuit`-tagged pages get their dependencies+/// without every passive needing its own wiki page.+fn circuit_bom(slug: &str) -> Vec<Value> {+    let block: Value = match wiki_bytes(slug, "adom-block.json").and_then(|b| serde_json::from_slice(&b).ok()) {+        Some(v) => v,+        None => return vec![],+    };+    let instances = match block.get("instances").and_then(|i| i.as_array()) {+        Some(a) => a,+        None => return vec![],+    };+    // aggregate by (value, lcsc, footprint)+    let mut order: Vec<String> = vec![];+    let mut agg: std::collections::HashMap<String, Value> = std::collections::HashMap::new();+    for inst in instances {+        let get = |k: &str| inst.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();+        let (value, lcsc, footprint) = (get("value"), get("lcsc"), get("footprint"));+        let refdes = get("id");+        let key = format!("{value}|{lcsc}|{footprint}");+        let entry = agg.entry(key.clone()).or_insert_with(|| {+            order.push(key.clone());+            json!({+                "value": if value.is_empty() { Value::Null } else { json!(value) },+                "lcsc": if lcsc.is_empty() { Value::Null } else { json!(lcsc) },+                "footprint": if footprint.is_empty() { Value::Null } else { json!(footprint) },+                "mpn": inst.get("mpn").cloned().unwrap_or(Value::Null),+                "page": inst.get("page").cloned().unwrap_or(Value::Null),+                "refs": json!([]),+                "qty": 0,+            })+        });+        entry["qty"] = json!(entry["qty"].as_u64().unwrap_or(0) + 1);+        if let Some(a) = entry["refs"].as_array_mut() { if !refdes.is_empty() { a.push(json!(refdes)); } }+    }+    order.into_iter().filter_map(|k| agg.remove(&k)).collect()+}+ fn board_deps(slug: &str) -> Value {     let mut components: Vec<String> = vec![];     let mut seen = std::collections::HashSet::new();+    let own_lib = format!("{slug}-library");     for d in deps_of(slug) {         let sub = deps_of(&d);         if sub.is_empty() {+            // The page's own library (a <slug>-library that lists no components yet, as+            // with a circuit whose BOM lives in its block model) is surfaced via the+            // "Libraries" expander, not as a fake component row. Skip it so the BOM shows.+            if d == own_lib { continue; }             if seen.insert(d.clone()) {                 components.push(d);             }@@ -957,16 +924,18 @@ fn board_deps(slug: &str) -> Value {         }     }     let items: Vec<Value> = components.into_iter().map(|s| json!({"slug": s})).collect();+    // No resolved component pages? fall back to the block-model BOM (circuit pages).+    let bom = if items.is_empty() { circuit_bom(slug) } else { vec![] };     // the page's git commit history (the wiki's Activity tab)     let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))         .and_then(|b| serde_json::from_slice::<Value>(&b).ok())         .and_then(|v| v.get("log").cloned())         .unwrap_or_else(|| json!([]));-    json!({ "status": "ok", "board": slug, "deps": items, "log": log,+    json!({ "status": "ok", "board": slug, "deps": items, "bom": bom, "log": log,         "hints": ["board → component library → components (the library's dependencies)"] }) } -/// A PROJECT's direct board dependencies — a page tagged `project` depends on+/// A PROJECT's direct board dependencies, a page tagged `project` depends on /// boards (not components), so unlike `board_deps` these are NOT expanded. The /// Manager lists them, and clicking one opens it as a normal board. fn project_boards(slug: &str) -> Value {@@ -980,7 +949,7 @@ fn project_boards(slug: &str) -> Value {  /// The board's EDA toolchain, inferred from the files on its page: /// "kicad" | "altium" | "fusion" | "".-/// Every EDA toolchain whose files are present on the board — a board can carry+/// 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> {@@ -1046,7 +1015,7 @@ fn board_activity(slug: &str) -> Value {     let mut out = vec![];     for i in 0..log.len() {         let mut e = log[i].clone();-        // compare against the previous (older) commit — the next entry in the log+        // compare against the previous (older) commit, the next entry in the log         if i + 1 < log.len() {             e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);             e["sch_changed"] = json!(schs[i] != schs[i + 1]);@@ -1061,7 +1030,7 @@ fn board_activity(slug: &str) -> Value {     json!({ "activity": out }) } -/// List the file names on a page (robust to any MPN→filename convention — the+/// List the file names on a page (robust to any MPN→filename convention, the /// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.). fn list_page_files(slug: &str) -> Vec<String> {     let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {@@ -1110,7 +1079,7 @@ fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), Stri }  // ----------------------------------------------------------------------------// Layer stackup — the cross-EDA mapping (KiCad + Altium)+// Layer stackup, the cross-EDA mapping (KiCad + Altium) // ---------------------------------------------------------------------------  fn role_name(l: &FpLayer) -> &'static str {@@ -1143,7 +1112,7 @@ fn altium_layer(l: &FpLayer) -> &'static str {     } } -/// CSS-safe token per layer role — used to class SVG elements so the footprint+/// CSS-safe token per layer role, used to class SVG elements so the footprint /// view can toggle layers on/off. fn layer_key(l: &FpLayer) -> &'static str {     match l {@@ -1250,7 +1219,7 @@ fn symbol_svg(sym: &Symbol) -> String {         }     }     // Show pin numbers/names on ICs; hide on passives (respect the source flag,-    // else default by pin count — same rule the KiCad exporter uses).+    // else default by pin count, same rule the KiCad exporter uses).     let passive = sym.pins.len() <= 2;     let show_num = !sym.pin_numbers_hidden.unwrap_or(passive);     let show_name = !sym.pin_names_hidden.unwrap_or(passive);@@ -1356,7 +1325,7 @@ fn empty_svg(msg: &str) -> String { }  // ----------------------------------------------------------------------------// Add to Library — convert selected components → the user's KiCad libraries,+// Add to Library, convert selected components → the user's KiCad libraries, // delivered to their machine via the Adom Desktop bridge. // --------------------------------------------------------------------------- @@ -1371,6 +1340,28 @@ fn ad_run(cmd: &str) -> Option<String> {     v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) } +/// The connected desktop's OS (`"windows"` | `"macos"` | `"linux"`), read from the+/// UNGATED `adom-desktop targets` verb. This needs no shell approval and no shell+/// probe, so it's the one OS signal that stays reliable even when `shell_execute`+/// is gated or down. None if the bridge is down or no desktop is connected.+fn ad_target_os() -> Option<String> {+    let out = Command::new("adom-desktop").arg("targets").output().ok()?;+    if !out.status.success() {+        return None;+    }+    let v: Value = serde_json::from_slice(&out.stdout).ok()?;+    let os = v.get("targets")?.as_array()?.first()?.get("os")?.as_str()?.to_lowercase();+    if os.contains("win") {+        Some("windows".into())+    } else if os.contains("mac") || os.contains("darwin") {+        Some("macos".into())+    } else if os.contains("linux") {+        Some("linux".into())+    } else {+        None+    }+}+ /// The user's machine `(os, home)`, detected via the bridge and cached. Files are /// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux /// container, so delivery paths must be built for it.@@ -1379,19 +1370,36 @@ fn machine() -> (String, String) {     if let Some(v) = MACHINE.lock().unwrap().clone() {         return v;     }-    // a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.-    let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {-        Some(home) => {-            let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };-            (os.to_string(), home)-        }-        None => {-            let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());-            ("windows".to_string(), home)-        }-    };-    *MACHINE.lock().unwrap() = Some(res.clone());-    res+    // The SHELL PROBE is authoritative for the OS, not the `targets` os label. The file+    // browser runs its `ls`/`dir` through the very same shell that answers this probe, so+    // whatever that shell reports IS the environment file ops happen in. Trusting the+    // `targets` label instead stranded us in Windows mode on a Linux box: the adom-desktop+    // Linux port mis-reports os="Windows" in `targets` (a machine literally named+    // "BarrettLaptopUbuntu" whose shell answers `echo $HOME` → /home/barrett-land). We ONLY+    // cache a CONFIDENT detection (a real home resolved via the shell); if the shell is+    // gated/down we return an OS-appropriate tentative WITHOUT caching (falling back to the+    // ungated `targets` hint), so the next call re-detects once the shell is reachable.+    // A Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.+    if let Some(home) = ad_run("echo $HOME").filter(|h| h.starts_with('/')) {+        let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };+        let res = (os.to_string(), home);+        *MACHINE.lock().unwrap() = Some(res.clone());+        return res;+    }+    // Not a Unix shell → a Windows cmd shell (echo %USERPROFILE% → C:\Users\...).+    if let Some(home) = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')) {+        let res = ("windows".to_string(), home);+        *MACHINE.lock().unwrap() = Some(res.clone());+        return res;+    }+    // No shell probe resolved (shell gated / bridge down) — fall back to the ungated+    // `targets` OS hint, UNCACHED, so we re-detect once the shell is reachable. Never seed+    // the Linux container's `/root` for a machine the hint says is Windows.+    match ad_target_os().as_deref() {+        Some("windows") => ("windows".to_string(), "C:\\Users".to_string()),+        Some("macos") => ("macos".to_string(), "/root".to_string()),+        _ => ("linux".to_string(), "/root".to_string()),+    } }  /// The path separator for the user's OS.@@ -1409,10 +1417,55 @@ fn home_path(parts: &[&str]) -> String {     p } +/// The user's real KiCad folder NAME under ~/Documents, matched case-INSENSITIVELY so we+/// reuse an existing "KiCAD" / "kicad" / "Kicad" instead of creating a duplicate "KiCad"+/// on a case-sensitive (Linux/macOS) filesystem — "if a kicad folder already exists it+/// shouldn't make another one". Cached once the desktop answers; falls back to "KiCad"+/// (uncached when the bridge is unreachable) so it re-detects later.+static KICAD_ROOT: Mutex<Option<String>> = Mutex::new(None);+fn kicad_root() -> String {+    if let Some(v) = KICAD_ROOT.lock().unwrap().clone() {+        return v;+    }+    let docs = home_path(&["Documents"]);+    let dirs = list_dirs(&docs)+        .get("dirs")+        .and_then(|d| d.as_array())+        .cloned()+        .unwrap_or_default();+    let matches: Vec<String> = dirs+        .iter()+        .filter_map(|d| d.get("name").and_then(|n| n.as_str()))+        .filter(|n| n.eq_ignore_ascii_case("kicad"))+        .map(String::from)+        .collect();+    let chosen = if matches.len() > 1 {+        // Multiple case-variants exist (e.g. an accidental "KiCad" beside the real+        // "KiCAD" from before this fix). Reuse the MOST-POPULATED one — the user's real+        // library — never an empty duplicate. Tie-break: keep the first (sorted) name.+        matches.into_iter().max_by_key(|name| {+            let p = format!("{docs}{}{name}", sep());+            list_dirs(&p).get("dirs").and_then(|d| d.as_array()).map(|a| a.len()).unwrap_or(0)+        })+    } else {+        matches.into_iter().next()+    };+    if let Some(name) = chosen {+        *KICAD_ROOT.lock().unwrap() = Some(name.clone());+        return name;+    }+    // Documents listed but no KiCad folder → "KiCad" will be created; cache it. An empty+    // listing means the bridge couldn't read Documents (down/gated) → stay tentative.+    if !dirs.is_empty() {+        *KICAD_ROOT.lock().unwrap() = Some("KiCad".to_string());+    }+    "KiCad".to_string()+}+ /// A KiCad library/project subdir on the user's machine (`sub` = Symbols /-/// Footprints / Projects).+/// Footprints / Projects), under the user's REAL KiCad folder (case-insensitive). fn kicad_dir(sub: &str) -> String {-    home_path(&["Documents", "KiCad", sub])+    home_path(&["Documents", kicad_root().as_str(), sub]) }  /// Everything before the last path separator (the containing directory).@@ -1423,7 +1476,7 @@ fn parent_dir(path: &str) -> String {     } } -/// GET /api/env — the user's machine OS + home + OS-appropriate default delivery+/// GET /api/env, the user's machine OS + home + OS-appropriate default delivery /// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux. fn api_env() -> Value {     let (os, home) = machine();@@ -1450,7 +1503,91 @@ fn kicad_config_base() -> String {     } } -/// True for a KiCad-bundled standard library (as opposed to the user's own) —+/// GET /api/create-library?kind=sym|fp&name=<Name>, create a NEW personal KiCad+/// library on the user's machine and register it in every KiCad version's lib table.+///+/// Parts whose manufacturer has no library yet (a Samsung capacitor when the footprint+/// libraries are per-manufacturer) previously had nowhere correct to go: the only+/// options were the wrong existing libraries. This makes "put it in a new+/// Samsung_BLT" a first-class choice.+///+/// symbols: `<dir>/<Name>.kicad_sym` seeded with an empty `kicad_symbol_lib`.+/// footprints: `<dir>/<Name>.pretty/` (a KiCad footprint library IS a directory).+fn create_library(kind: &str, name: &str) -> Value {+    // a library name has to be safe as both a filename and a lib-table nickname+    let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == ' ').collect();+    let clean = clean.trim().to_string();+    if clean.is_empty() {+        return json!({"ok": false, "error": "a library name is required"});+    }+    let is_sym = kind == "sym";+    let table = if is_sym { "sym-lib-table" } else { "fp-lib-table" };+    let libs = user_libraries();+    let key = if is_sym { "symLibs" } else { "fpLibs" };+    if libs.get(key).and_then(|a| a.as_array())+        .map(|a| a.iter().any(|l| l.get("name").and_then(|n| n.as_str()).map(|n| n.eq_ignore_ascii_case(&clean)).unwrap_or(false)))+        .unwrap_or(false)+    {+        return json!({"ok": false, "error": format!("you already have a library called {clean}")});+    }+    let (os, home) = machine();+    let win = os == "windows";+    let sep = if win { '\\' } else { '/' };+    // put it beside the user's existing personal libraries, else the conventional spot+    let sibling = libs.get(key).and_then(|a| a.as_array()).and_then(|a| a.first())+        .and_then(|l| l.get("uri")).and_then(|u| u.as_str())+        .and_then(|u| u.rfind(['/', '\\']).map(|i| u[..i].to_string()));+    let dir = sibling.unwrap_or_else(|| format!("{home}{sep}Documents{sep}KiCAD{sep}{}", if is_sym { "Symbols" } else { "Footprints" }));+    let path = if is_sym { format!("{dir}{sep}{clean}.kicad_sym") } else { format!("{dir}{sep}{clean}.pretty") };+    // 1. create the library itself+    let make = if is_sym {+        let body = "(kicad_symbol_lib (version 20211014) (generator \"adom-project-manager\")\n)\n";+        if win {+            ps_command(&format!("New-Item -ItemType Directory -Force -Path '{dir}' | Out-Null; Set-Content -LiteralPath '{path}' -Value @'\n{body}\n'@ -NoNewline -Encoding utf8"))+        } else {+            format!("mkdir -p {} && printf '%s' {} > {}", sh_quote(&dir), sh_quote(body), sh_quote(&path))+        }+    } else if win {+        ps_command(&format!("New-Item -ItemType Directory -Force -Path '{path}' | Out-Null"))+    } else {+        format!("mkdir -p {}", sh_quote(&path))+    };+    if ad_run(&make).is_none() && !win {+        // mkdir/printf print nothing on success, so verify instead of trusting output+        let check = format!("test -e {} && echo ok", sh_quote(&path));+        if ad_run(&check).is_none() {+            return json!({"ok": false, "error": format!("could not create {path} on your machine")});+        }+    }+    // 2. register it in EVERY installed KiCad version's table, so 9.x and 10.x both see it+    let base = kicad_config_base();+    let entry = format!("  (lib (name \"{clean}\")(type \"KiCad\")(uri \"{path}\")(options \"\")(descr \"created by Adom Project Manager\"))");+    let reg = if win {+        ps_command(&format!(+            "Get-ChildItem -Path '{base}' -Directory | ForEach-Object {{ $t = Join-Path $_.FullName '{table}'; \+             if (Test-Path $t) {{ $c = Get-Content -Raw $t; if ($c -notmatch [regex]::Escape('\"{clean}\"')) {{ \+             $i = $c.LastIndexOf(')'); if ($i -ge 0) {{ Set-Content -LiteralPath $t -Value ($c.Substring(0,$i) + \"{entry}`n\" + $c.Substring($i)) -NoNewline }} }} }} }}"))+    } else {+        // insert before the table's final ')' — awk keeps the file valid s-expression+        format!(+            "for t in {base}/*/{table}; do [ -f \"$t\" ] || continue; grep -q '\"{clean}\"' \"$t\" && continue; \+             awk -v e={} 'BEGIN{{done=0}} {{lines[NR]=$0}} END{{ last=0; for(i=NR;i>=1;i--) if(lines[i] ~ /^[ \\t]*\\)[ \\t]*$/) {{last=i;break}} \+             for(i=1;i<=NR;i++){{ if(i==last) print e; print lines[i] }} if(last==0) print e }}' \"$t\" > \"$t.adomtmp\" && mv \"$t.adomtmp\" \"$t\"; done; echo ok",+            sh_quote(&entry))+    };+    ad_run(&reg);+    // 3. confirm it now shows up as one of the user's libraries+    let after = user_libraries();+    let listed = after.get(key).and_then(|a| a.as_array())+        .map(|a| a.iter().any(|l| l.get("name").and_then(|n| n.as_str()).map(|n| n == clean).unwrap_or(false)))+        .unwrap_or(false);+    json!({"ok": listed, "name": clean, "path": path, "kind": kind,+           "error": if listed { Value::Null } else { json!(format!("created {path} but KiCad's {table} did not pick it up")) },+           "symLibs": after.get("symLibs").cloned().unwrap_or(json!([])),+           "fpLibs": after.get("fpLibs").cloned().unwrap_or(json!([]))})+}++/// True for a KiCad-bundled standard library (as opposed to the user's own) - /// identified by a `${KICAD*_…_DIR}` variable or an install-dir path. fn is_stock_kicad_lib(uri: &str) -> bool {     let u = uri.to_lowercase();@@ -1477,13 +1614,17 @@ fn parse_lib_table(text: &str) -> Vec<Value> {         .collect() } -/// GET /api/libraries — the user's REAL KiCad symbol + footprint libraries (name+/// GET /api/libraries, the user's REAL KiCad symbol + footprint libraries (name /// + on-disk path) read from their global sym-lib-table / fp-lib-table via the-/// bridge, so placement suggests the user's own libraries — never hard-coded ones.+/// bridge, so placement suggests the user's own libraries, never hard-coded ones. fn user_libraries() -> Value {     let (os, _) = machine();     let base = kicad_config_base();-    let read = |table: &str| -> Vec<Value> {+    // returns (the user's PERSONAL libraries, the names of KiCad's BUNDLED ones).+    // The stock names are reported too — not to offer them as targets, but so the UI+    // can label a library that is KiCad's rather than the user's own (a part already+    // in their library very often points at a stock footprint library).+    let read = |table: &str| -> (Vec<Value>, Vec<String>) {         let cmd = if os == "windows" {             format!("for /d %d in (\"{base}\\*\") do @type \"%d\\{table}\" 2>nul")         } else {@@ -1491,20 +1632,34 @@ fn user_libraries() -> Value {         };         // merge across KiCad versions, first occurrence of each name wins         let mut seen = std::collections::HashSet::new();-        ad_run(&cmd)-            .map(|t| parse_lib_table(&t))-            .unwrap_or_default()-            .into_iter()-            .filter(|l| {-                let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();-                let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("");-                // keep only the user's PERSONAL libraries: drop KiCad's bundled-                // standard libs and the built-in default-table pointer.-                n != "KiCad" && !is_stock_kicad_lib(uri) && seen.insert(n)-            })-            .collect()+        let mut personal = vec![];+        let mut stock = vec![];+        for l in ad_run(&cmd).map(|t| parse_lib_table(&t)).unwrap_or_default() {+            let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();+            let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("").to_string();+            if n.is_empty() || !seen.insert(n.clone()) {+                continue;+            }+            if n == "KiCad" || is_stock_kicad_lib(&uri) {+                stock.push(n);+            } else {+                personal.push(l);+            }+        }+        (personal, stock)     };-    json!({ "symLibs": read("sym-lib-table"), "fpLibs": read("fp-lib-table") })+    let (sym, sym_stock) = read("sym-lib-table");+    let (fp, fp_stock) = read("fp-lib-table");+    // `machine` = the desktop was CONFIDENTLY detected (bridge reachable).+    // `ready` = that AND we actually READ a lib table. Detection alone is not enough:+    // a detected machine whose shell probe came back empty (bridge still warming up,+    // shell permission/reason gate, transient failure) is NOT a trustworthy "you have+    // no libraries" answer. Reporting ready on it made the UI latch an empty list and+    // render every library dropdown dead, with no recovery short of a page reload.+    let machine_ok = MACHINE.lock().unwrap().is_some();+    let ready = machine_ok && !(sym.is_empty() && fp.is_empty());+    json!({ "symLibs": sym, "fpLibs": fp, "ready": ready, "machine": machine_ok,+            "stockSymLibs": sym_stock, "stockFpLibs": fp_stock }) }  /// Minimal percent-decoding for query values (`%20`, `+`, `%2F`, …).@@ -1526,7 +1681,7 @@ fn urldecode(s: &str) -> String {     String::from_utf8_lossy(&out).into_owned() } -/// GET /api/pick-folder — pop a NATIVE folder-picker dialog on the user's machine+/// GET /api/pick-folder, pop a NATIVE folder-picker dialog on the user's machine /// (via the bridge) and return the chosen directory. Blocks until the user picks /// or cancels (empty path = cancelled / no dialog available). fn pick_folder() -> Value {@@ -1569,11 +1724,11 @@ fn pick_folder() -> Value {     json!({ "token": id }) } -/// GET /api/pick-folder-result?f=<id> — poll the detached folder dialog: no file+/// 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+    // 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 });@@ -1599,11 +1754,11 @@ fn pick_folder_result(token: &str) -> Value {     } } -/// GET /api/list-dirs?p=<abspath> — the sub-folders of a directory on the user's+/// 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+/// 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();@@ -1642,7 +1797,7 @@ fn list_dirs(path: &str) -> Value {     } } -/// GET /api/suggest-path?p=<prefix> — existing directories on the user's machine+/// GET /api/suggest-path?p=<prefix>, existing directories on the user's machine /// whose path starts with the typed prefix (for the delivery autosuggest). fn suggest_path(prefix: &str) -> Value {     let (os, _) = machine();@@ -1736,6 +1891,10 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,     // Download the 3D models the board references into work/3d/ so they ship with the     // project and the ${KIPRJMOD}/3d/<base> refs resolve on the recipient's machine.     stage_board_models(board, &work, &models, &proj_models, shapes_dir.as_deref());+    // Anything the page didn't carry (a custom manufacturer model the uploader referenced+    // by an absolute path, e.g. a WAGO .step) — source it from the recipient's OWN KiCad 3D+    // libraries so Add Project still delivers its 3D.+    source_local_models(&work, &models);     ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));      if project_only {@@ -1793,7 +1952,7 @@ fn sh_quote(s: &str) -> String { }  /// Library nicknames become file/dir names on the user's machine and are-/// interpolated into shell commands — restrict to a boring identifier charset.+/// interpolated into shell commands, restrict to a boring identifier charset. fn valid_lib(s: &str) -> bool {     !s.is_empty()         && s.len() <= 64@@ -1809,7 +1968,7 @@ fn valid_slug(s: &str) -> bool { }  /// Pull a file off the user's machine to the container; None if it doesn't-/// exist OR the pull failed — callers that overwrite must disambiguate via+/// exist OR the pull failed, callers that overwrite must disambiguate via /// `ad_file_exists` before treating None as "absent". fn ad_pull(remote: &str) -> Option<Vec<u8>> {     let _ = std::fs::create_dir_all(SCRATCH);@@ -1832,6 +1991,43 @@ fn ad_send(files: &[String], dest: &str) -> bool { /// Ship the board's bundled 3D models (staged into `work/3d/` by stage_board_models) /// to `<dest>/3d/`, so the delivered project's rewritten `${KIPRJMOD}/3d/<base>` model /// refs resolve on the recipient's machine (issue #6). Returns the delivered filenames.+/// Fill any 3D model the wiki page did NOT carry from the user's OWN KiCad 3D libraries on+/// the connected desktop, searched OS-appropriately under their real KiCad folder, and pull+/// it into `work/3d/` so it ships with the project. Delivers 3D for manufacturer parts the+/// board references by an absolute path that only exists on the uploader's machine (e.g. a+/// WAGO `.step`) whenever the recipient has the same model locally. The delivered PCB already+/// points every custom model at the portable `${KIPRJMOD}/3d/<base>`, so we never write the+/// uploader's OS-specific path structure onto this machine.+fn source_local_models(work: &str, models: &std::collections::HashSet<String>) {+    let (os, _) = machine();+    let root = home_path(&["Documents", kicad_root().as_str()]);+    for base in models {+        let local = format!("{work}/3d/{base}");+        if std::path::Path::new(&local).exists() {+            continue; // already fetched from the wiki page+        }+        let found = if os == "windows" {+            // `where /r <dir> <name>` recursively lists matching full paths.+            ad_run(&format!("where /r \"{}\" {}", root.replace('"', ""), base.replace('"', "")))+        } else {+            ad_run(&format!(+                "find {} -type f -iname {} 2>/dev/null | head -1",+                sh_quote(&root),+                sh_quote(base)+            ))+        };+        if let Some(path) = found+            .and_then(|o| o.lines().next().map(|l| l.trim().to_string()))+            .filter(|s| !s.is_empty())+        {+            if let Some(bytes) = ad_pull(&path) {+                let _ = std::fs::create_dir_all(format!("{work}/3d"));+                let _ = std::fs::write(&local, bytes);+            }+        }+    }+}+ fn deliver_models(work: &str, dest: &str) -> Vec<String> {     let m3 = format!("{work}/3d");     let paths: Vec<String> = match std::fs::read_dir(&m3) {@@ -2012,7 +2208,7 @@ fn render_sch_project(slug: &str) -> Vec<(String, String)> { /// `${KIPRJMOD}/3d/<basename>` and collect what to stage. Custom = anything NOT a /// `${KICAD*_3DMODEL_DIR}` standard ref: a machine-absolute Unix path (/home/…), a /// Windows path (C:\Users\…), OR a project-relative path (<Board>.3dshapes/…). This-/// makes the board machine-independent — the same fix both the GLB render and the+/// makes the board machine-independent, the same fix both the GLB render and the /// delivered project need (issue #6: uploader absolute paths leak to the recipient). /// Returns (rewritten_pcb, custom-model basenames, `${KIPRJMOD}/<rel>` project models). fn normalize_pcb_models(@@ -2029,10 +2225,23 @@ fn normalize_pcb_models(             Some(end) => {                 let path = &after[..end];                 if path.starts_with("${") {-                    if let Some(rel) = path.replace('\\', "/").strip_prefix("${KIPRJMOD}/") {+                    let norm = path.replace('\\', "/");+                    if let Some(rel) = norm.strip_prefix("${KIPRJMOD}/") {+                        // already project-bundled — keep the path, fetch that relative file                         proj_models.insert(rel.to_string());+                        out.push_str(path);+                    } else if norm.starts_with("${KICAD") {+                        // a STANDARD KiCad 3D-model dir (${KICADn_3DMODEL_DIR}) resolves on any+                        // KiCad install, so leave it untouched (never bundle the whole standard lib).+                        out.push_str(path);+                    } else {+                        // a NON-standard env var (e.g. ${EASYEDA2KICAD}, ${KISYS3DMOD}) — the+                        // recipient won't have it set, so treat it like a custom model: bundle the+                        // file + repath to the portable ${KIPRJMOD}/3d/<base>.+                        let base = norm.rsplit('/').next().unwrap_or(norm.as_str());+                        models.insert(base.to_string());+                        out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));                     }-                    out.push_str(path);                 } else {                     let base = path.rsplit(['/', '\\']).next().unwrap_or(path);                     models.insert(base.to_string());@@ -2091,7 +2300,7 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {     let (out, models, proj_models) = normalize_pcb_models(&pcb);     // Show DNP (Do-Not-Populate) parts in the 3D view. KiCad's 3D export omits DNP     // footprints by default, so a part left/mis-flagged DNP (e.g. a main IC) renders-    // blank with no body — surprising in a design-browsing tool. Drop the `dnp` attr+    // blank with no body, surprising in a design-browsing tool. Drop the `dnp` attr     // token so every placed footprint gets its model.     let out = out.replace(" dnp)", ")").replace(" dnp ", " ");     // assemble the bundle: board.kicad_pcb (rewritten) + 3d/<custom models>@@ -2106,16 +2315,232 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {         return None;     }     let bytes = std::fs::read(&targz).ok()?;-    kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)+    let raw = kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)?;+    // kicad-cli emits one primitive per part feature (a complex board is thousands+    // of draw calls) and no compression, so a dense board like the USB3->Ethernet+    // molecule loads and orbits slowly. Collapse it before publishing.+    Some(optimize_glb(&raw, &key))+}++/// Make the raw kicad-cli board GLB lean for the 3D viewer: merge primitives by+/// material (thousands of draw calls -> a few dozen), weld/prune, and apply+/// KHR_mesh_quantization (Babylon-native, needs no decoder — NOT draco, per the+/// molecule pipeline's "don't draco molecule-sized GLBs" rule). Geometry is+/// unchanged (same triangles). Falls back to the raw bytes if the optimizer or+/// node is unavailable, so a board still renders even without the toolchain.+fn optimize_glb(bytes: &[u8], key: &str) -> Vec<u8> {+    // Locate glb_optimize.mjs: env override, then next to the binary, then the+    // install share dir (where install.sh puts it + its node_modules), then the+    // dev checkout. First that exists wins; if none, return the raw GLB unchanged.+    let mut cands: Vec<String> = Vec::new();+    if let Ok(p) = std::env::var("ADOM_GLB_OPTIMIZE") { cands.push(p); }+    if let Ok(exe) = std::env::current_exe() {+        if let Some(d) = exe.parent() { cands.push(d.join("glb_optimize.mjs").to_string_lossy().into_owned()); }+    }+    if let Ok(home) = std::env::var("HOME") {+        cands.push(format!("{home}/.local/share/adom-project-manager/glb_optimize.mjs"));+    }+    cands.push("/home/adom/project/glb_optimize.mjs".to_string());+    let opt = match cands.into_iter().find(|p| std::path::Path::new(p).exists()) {+        Some(p) => p, None => return bytes.to_vec(),+    };+    let tin = format!("{SCRATCH}/glbopt-{key}-in.glb");+    let tout = format!("{SCRATCH}/glbopt-{key}-out.glb");+    if std::fs::write(&tin, bytes).is_err() { return bytes.to_vec(); }+    let _ = std::fs::remove_file(&tout);+    let ok = Command::new("node").args([opt.as_str(), &tin, &tout]).status().map(|s| s.success()).unwrap_or(false);+    let out = if ok { std::fs::read(&tout).ok().filter(|b| b.len() > 100) } else { None };+    let _ = std::fs::remove_file(&tin); let _ = std::fs::remove_file(&tout);+    out.unwrap_or_else(|| bytes.to_vec()) }  /// Fetch a board's .kicad_pcb + full hierarchical schematic and render the /// schematic sheets, the PCB SVG, and the board GLB via service-kicad.+fn toast_queue() -> &'static Mutex<Vec<serde_json::Value>> {+    static Q: std::sync::OnceLock<Mutex<Vec<serde_json::Value>>> = std::sync::OnceLock::new();+    Q.get_or_init(|| Mutex::new(Vec::new()))+}++fn board_view_cache() -> &'static Mutex<std::collections::HashMap<String, String>> {+    static C: std::sync::OnceLock<Mutex<std::collections::HashMap<String, String>>> = std::sync::OnceLock::new();+    C.get_or_init(|| Mutex::new(std::collections::HashMap::new()))+}++/// Render the interactive PCB / schematic view HTML by shelling the standalone+/// viewers (`adom-2dboard` / `adom-schematic` `embed`) on the board's+/// raw KiCad file (fetched from the wiki page). Cached per (slug, kind, version).+fn render_board_view(slug: &str, kind: &str, ver: Option<&str>) -> Option<String> {+    render_board_view_sheet(slug, kind, ver, None)+}++/// `sheet` selects a specific hierarchical child `.kicad_sch` (by basename) instead+/// of the project's root sheet, so a viewer can navigate into sub-sheets.+fn render_board_view_sheet(slug: &str, kind: &str, ver: Option<&str>, sheet: Option<&str>) -> Option<String> {+    let key = format!("{slug}|{kind}|{}|{}", ver.unwrap_or("cur"), sheet.unwrap_or(""));+    if let Some(h) = board_view_cache().lock().unwrap().get(&key) { return Some(h.clone()); }+    let files = list_page_files(slug);+    // Source files for EVERY supported EDA, best-first. The viewers detect the+    // format from the file itself, so the binary only depends on pcb-vs-sch:+    //   KiCad .kicad_pcb/.kicad_sch · Altium .PcbDoc/.SchDoc · Fusion/EAGLE .brd/.sch+    let (exts, bin) = match kind {+        "pcb" => (["kicad_pcb", "pcbdoc", "brd"].as_slice(), "adom-2dboard"),+        "sch" => (["kicad_sch", "schdoc", "sch"].as_slice(), "adom-schematic"),+        _ => return None,+    };+    let has_ext = |n: &str, e: &str| n.rsplit('.').next().map(|x| x.eq_ignore_ascii_case(e)).unwrap_or(false);+    // for schematics prefer the ROOT sheet (the one matching the project stem)+    let root_stem = files.iter()+        .find(|n| has_ext(n, "kicad_pro") || has_ext(n, "prjpcb"))+        .and_then(|n| n.rsplit('/').next())+        .and_then(|n| n.rsplit_once('.'))+        .map(|(stem, _)| stem.to_string());+    let mut fname = None;+    // an explicit child sheet wins: match the requested basename on the page+    if kind == "sch" {+        if let Some(want) = sheet {+            fname = files.iter().find(|n| n.rsplit('/').next().unwrap_or(n).eq_ignore_ascii_case(want)).cloned();+        }+    }+    if fname.is_none() {+        for e in exts {+            if kind == "sch" {+                if let Some(st) = &root_stem {+                    if let Some(f) = files.iter().find(|n| {+                        let base = n.rsplit('/').next().unwrap_or(n);+                        has_ext(base, e) && base.rsplit_once('.').map(|(s, _)| s == st).unwrap_or(false)+                    }) { fname = Some(f.clone()); break; }+                }+            }+            if let Some(f) = files.iter().find(|n| has_ext(n, e)) { fname = Some(f.clone()); break; }+        }+    }+    let fname = fname?;+    let ext = fname.rsplit('.').next().unwrap_or("bin").to_string();+    let bytes = wiki_bytes(slug, &fname)?;+    let dir = format!("{SCRATCH}/view-{}", slug.replace(['/', ' '], "_"));+    std::fs::create_dir_all(&dir).ok()?;+    // include the sheet basename so concurrent requests for different child sheets+    // don't race on one temp file+    let tag = sheet.map(|s| s.trim_end_matches(".kicad_sch")).unwrap_or("root");+    let inp = format!("{dir}/{kind}-{tag}.{ext}");+    let outp = format!("{dir}/{kind}-{tag}.html");+    std::fs::write(&inp, &bytes).ok()?;+    // For a KiCad schematic, also stage every OTHER .kicad_sch on the page next to+    // the input file and pass --sheet-dir, so the viewer bundles all child sheets+    // into the embed and hierarchical navigation is instant (no reload).+    let mut args: Vec<String> = vec!["embed".into(), "--file".into(), inp.clone(), "--out".into(), outp.clone()];+    if kind == "sch" && ext == "kicad_sch" {+        for n in files.iter().filter(|n| has_ext(n, "kicad_sch")) {+            let bn = n.rsplit('/').next().unwrap_or(n);+            if bn == inp.rsplit('/').next().unwrap_or(&inp) { continue; }+            if let Some(b) = wiki_bytes(slug, n) { let _ = std::fs::write(format!("{dir}/{bn}"), b); }+        }+        // Stage the AUTHENTIC pre-rendered sheet SVGs (molecule-publish renders the+        // FULL project, so these have real KiCad fonts and the hierarchical root is+        // complete) as `<sheetfile>.base.svg`, which the viewer uses verbatim instead+        // of re-exporting single files via service-kicad (which can't render a lone+        // hierarchical root). render/manifest.json lists sheet NAMES in sheet-NNN order.+        stage_authentic_sheets(slug, &dir, &inp, &bytes);+        args.push("--sheet-dir".into()); args.push(dir.clone());+    }+    let ok = Command::new(bin).args(&args).status().map(|s| s.success()).unwrap_or(false);+    if !ok { eprintln!("board-view: {bin} embed failed for {slug}"); return None; }+    let html = std::fs::read_to_string(&outp).ok()?;+    board_view_cache().lock().unwrap().insert(key, html.clone());+    Some(html)+}++/// Fetch the page's authentic pre-rendered `render/sheet-NNN.svg` and stage each as+/// `<dir>/<sheetfile>.base.svg` so the viewer renders every sheet from the real+/// KiCad export. `render/manifest.json` gives sheet NAMES in sheet-NNN order; we map+/// name -> file via the root's own `(sheet (property "Sheetname")(property "Sheetfile"))`.+fn stage_authentic_sheets(slug: &str, dir: &str, root_inp: &str, root_bytes: &[u8]) {+    // manifest sheet names, in sheet-NNN.svg order+    let names: Vec<String> = match wiki_bytes(slug, "render/manifest.json")+        .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())+        .and_then(|m| m.get("sheets").and_then(|s| s.as_array()).map(|a|+            a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()))+    { Some(v) => v, None => return };+    // name -> child file, parsed from the root sheet (no regex crate here). A sheet+    // symbol carries `(property "Sheetname" "X")` then `(property "Sheetfile" "x")`.+    let root_txt = String::from_utf8_lossy(root_bytes);+    let quoted_after = |hay: &str, key: &str| -> Option<(String, usize)> {+        let k = format!("\"{key}\"");+        let ki = hay.find(&k)? + k.len();+        let q1 = hay[ki..].find('"')? + ki + 1;+        let q2 = hay[q1..].find('"')? + q1;+        Some((hay[q1..q2].to_string(), q2))+    };+    let mut name_file: std::collections::HashMap<String, String> = std::collections::HashMap::new();+    let mut cur = &root_txt[..];+    while let Some((name, end)) = quoted_after(cur, "Sheetname") {+        let rest = &cur[end..];+        // Sheetfile must be within the same sheet block (nearby)+        if let Some((file, fend)) = quoted_after(&rest[..rest.len().min(600)], "Sheetfile") {+            name_file.insert(name, file);+            cur = &rest[fend..];+        } else { break; }+    }+    let root_base = root_inp.rsplit('/').next().unwrap_or(root_inp).to_string();+    for (i, name) in names.iter().enumerate() {+        let svg = match wiki_bytes(slug, &format!("render/sheet-{i:03}.svg")) { Some(s) => s, None => continue };+        // index 0 (the "Root") -> the root input file; others -> the mapped child file+        let target = if i == 0 { root_base.clone() } else { match name_file.get(name) { Some(f) => f.clone(), None => continue } };+        let _ = std::fs::write(format!("{dir}/{target}.base.svg"), svg);+    }+}++/// Serve `/enrich` for an embedded viewer: run `adom-schematic enrich` and+/// return its JSON. Cached per part so repeated hovers are instant (and so we don't+/// hammer JLCPCB from a mousemove handler).+fn board_view_enrich(url: &str) -> String {+    let q = url.split_once('?').map(|(_, q)| q).unwrap_or("");+    let get = |k: &str| -> String {+        q.split('&')+            .find_map(|kv| kv.strip_prefix(&format!("{k}=")))+            .map(|v| pct(v))+            .unwrap_or_default()+    };+    let (mpn, lcsc) = (get("mpn"), get("lcsc"));+    if mpn.is_empty() && lcsc.is_empty() { return "{\"error\":\"no query\"}".into(); }+    let key = format!("enrich|{mpn}|{lcsc}");+    if let Some(hit) = board_view_cache().lock().unwrap().get(&key) { return hit.clone(); }+    let out = Command::new("adom-schematic")+        .args(["enrich", "--mpn", &mpn, "--lcsc", &lcsc])+        .output().ok()+        .filter(|o| o.status.success())+        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())+        .filter(|s| s.starts_with('{'))+        .unwrap_or_else(|| "{\"error\":\"lookup failed\"}".into());+    board_view_cache().lock().unwrap().insert(key, out.clone());+    out+}++/// minimal percent-decode (+ as space) for query values+fn pct(s: &str) -> String {+    let b = s.as_bytes();+    let mut out = String::with_capacity(s.len());+    let mut i = 0;+    while i < b.len() {+        match b[i] {+            b'+' => { out.push(' '); i += 1; }+            b'%' if i + 2 < b.len() => {+                match u8::from_str_radix(&s[i + 1..i + 3], 16) {+                    Ok(v) => { out.push(v as char); i += 3; }+                    Err(_) => { out.push('%'); i += 1; }+                }+            }+            c => { out.push(c as char); i += 1; }+        }+    }+    out+}+ fn render_board_media(slug: &str) -> BoardMedia {     let files = list_page_files(slug);     let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();     // schematic: render the whole hierarchical project so the root's sheet-    // symbols resolve (a single-file render can't) — one SVG per sheet, root first+    // symbols resolve (a single-file render can't), one SVG per sheet, root first     let sheets = render_sch_project(slug);     let pcb_bytes = pcb.as_ref().and_then(|n| wiki_bytes(slug, n));     // draw order = z-order (last on top): bottom/inner copper first (dimmed in@@ -2129,7 +2554,7 @@ fn render_board_media(slug: &str) -> BoardMedia {  /// The pre-rendered assets published to a board's `render/` folder, if any /// (manifest = which assets exist + the ordered sheet names). When present the-/// Manager serves them directly — no service-kicad, no wait — instead of+/// 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")@@ -2153,12 +2578,9 @@ fn board_media(slug: &str) -> Value {             "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!([])),-            // interactive viewer exports available for the CURRENT version (e.g.-            // ["layout","sch-usb",…]). Per-version availability rides on versions[].viewers.-            "viewers": m.get("viewers").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.+    // no stored render/ folder from here on, the board live-renders instead.     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, "has_render": false, "has_assets": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});@@ -2175,7 +2597,7 @@ fn board_media(slug: &str) -> Value {     if !is_kicad {         // Not a KiCad board (EAGLE/Fusion .brd/.sch, or STEP/GLB-only). We can't         // render the 2D PCB/schematic (service-kicad is KiCad-only), but if the page-        // ships a model GLB we can still show the 3D — served by the board-glb route's+        // ships a model GLB we can still show the 3D, served by the board-glb route's         // <slug>.glb fallback. So report has_3d from the GLB rather than giving up.         let has_3d = has(".glb");         return json!({"rendering": false, "is_kicad": false, "has_render": false,@@ -2200,7 +2622,7 @@ fn board_media(slug: &str) -> Value { }  /// Does a file exist on the user's machine? None = couldn't determine-/// (bridge/desktop error) — callers must treat that as "assume it exists".+/// (bridge/desktop error), callers must treat that as "assume it exists". fn ad_file_exists(path: &str) -> Option<bool> {     let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;     if out.contains("__EXISTS__") {@@ -2214,6 +2636,13 @@ fn ad_file_exists(path: &str) -> Option<bool> {  /// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a /// single-symbol .kicad_sym → (name, block).+/// Rename a symbol inside its `.kicad_sym` block. KiCad names each graphical+/// sub-unit `<name>_<unit>_<style>`, so every `(symbol "<old>` prefix has to move+/// together or the units orphan and the symbol renders blank in the library.+fn rename_symbol_block(block: &str, old: &str, new: &str) -> String {+    block.replace(&format!("(symbol \"{old}"), &format!("(symbol \"{new}"))+}+ fn extract_symbol_block(t: &str) -> Option<(String, String)> {     let i = t.find("(symbol \"")?;     let name = t[i + 9..].split('"').next()?.to_string();@@ -2238,7 +2667,7 @@ fn extract_symbol_block(t: &str) -> Option<(String, String)> { /// already present. fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {     let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {-        // Default tier (V8) — this lib lands on the user's desktop, whose+        // Default tier (V8), this lib lands on the user's desktop, whose         // KiCad (9.0.9) rejects files newer than itself.         let kv = altium_codec::kicad_export::KicadVersion::default();         format!(@@ -2271,7 +2700,7 @@ fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> Strin     format!("{}{}{}", &base[..cut], add, &base[cut..]) } -/// POST /api/apply — body {items:[{slug,symLib,fpLib}], target, project?, board?}.+/// POST /api/apply, body {items:[{slug,symLib,fpLib}], target, project?, board?}. /// Converts each component, merges symbols into the mapped KiCad symbol library, /// and drops footprints into the footprint `.pretty`, all on the user's machine. // Live progress for the background apply, polled by the UI via /api/apply-progress.@@ -2283,7 +2712,7 @@ fn get_progress() -> Value {     PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true})) } -/// POST /api/apply — validate, then run the work on a background thread so the UI+/// POST /api/apply, validate, then run the work on a background thread so the UI /// can poll live stage updates. Returns immediately with `{status:"started"}`. fn start_apply(body: &str) -> Value {     let req: Value = match serde_json::from_str(body) {@@ -2293,7 +2722,7 @@ fn start_apply(body: &str) -> Value {     if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {         return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});     }-    // One apply at a time — concurrent runs share SCRATCH and the PROGRESS cell.+    // One apply at a time, concurrent runs share SCRATCH and the PROGRESS cell.     {         let p = PROGRESS.lock().unwrap();         if let Some(v) = p.as_ref() {@@ -2310,7 +2739,7 @@ fn start_apply(body: &str) -> Value {         }     }     let mut bad: Vec<String> = vec![];-    let items: Vec<(String, String, String)> = req+    let items: Vec<(String, String, String, String)> = req         .get("items")         .and_then(|i| i.as_array())         .map(|a| {@@ -2322,12 +2751,20 @@ fn start_apply(body: &str) -> Value {                     }                     let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();                     let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();+                    // optional user-chosen name for the symbol inside their library+                    let name = it.get("symName").and_then(|s| s.as_str()).unwrap_or("").trim().to_string();                     // These become paths + shell args on the user's machine.                     if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {                         bad.push(slug);                         return None;                     }-                    Some((slug, sym, fp))+                    // a symbol name lands inside an s-expression string, so keep quotes+                    // and parens out of it+                    if name.contains('"') || name.contains('(') || name.contains(')') || name.contains('\n') {+                        bad.push(slug);+                        return None;+                    }+                    Some((slug, sym, fp, name))                 })                 .collect()         })@@ -2352,7 +2789,7 @@ fn start_apply(body: &str) -> Value { }  /// Every symbol name already present anywhere in the user's symbol libraries,-/// mapped to the library that has it — so an existing symbol is never re-added.+/// mapped to the library that has it, so an existing symbol is never re-added. /// Best-effort (Unix grep / Windows Select-String); empty on failure. fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, String> {     let mut idx = std::collections::HashMap::new();@@ -2423,6 +2860,75 @@ fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::     map } +/// For parts ALREADY in the user's symbol libraries, the footprint library that+/// symbol's own `Footprint` property points at (`Resistor_SMD:R_0402_1005Metric`+/// -> `Resistor_SMD`).+///+/// This is the authoritative answer and no name-based guess can reach it: the+/// footprint frequently lives in a STOCK KiCad library, which the personal-library+/// list deliberately filters out, so scanning the user's own .pretty folders finds+/// nothing and the UI was left guessing (and guessing wrong).+///+/// One pass over the symbol libraries, emitting only symbols whose name or property+/// values mention one of the MPNs we were asked about, so the payload stays small.+/// Returns (symbol name, concatenated property values, full `Lib:Footprint`).+fn symbol_declared_footprints(files: &[String], keys: &[String]) -> Vec<(String, String, String)> {+    let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();+    // Match on an ALPHANUMERIC-ONLY key. Two reasons: a library symbol is usually named+    // for its value/package (`C_10uF_6.3V_0402`) and never mentions the MPN, so the MPN+    // alone finds nothing; and a raw name like `R_1kR_0402_1%` is full of regex+    // metacharacters that would need escaping in every shell dialect. Stripping to+    // [a-z0-9] on BOTH sides makes the pattern regex-safe by construction.+    let pats: Vec<String> = keys.iter()+        .map(|m| norm_val(m))+        .filter(|s| s.len() >= 4)+        .collect::<std::collections::BTreeSet<_>>()+        .into_iter()+        .collect();+    if files.is_empty() || pats.is_empty() {+        return vec![];+    }+    let pat = pats.join("|");+    let (os, _) = machine();+    let out = if os == "windows" {+        let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");+        ps_command(&format!(+            "$ErrorActionPreference='SilentlyContinue';foreach($f in @({list})){{$cur='';$fp='';$props='';\+             foreach($line in [IO.File]::ReadLines($f)){{\+             if($line -match '^\\s*\\(symbol\\s+\"([^\"]+)\"'){{$nm=$Matches[1];if($nm -match '_\\d+_\\d+$'){{continue}};\+             if($cur -ne '' -and $fp -ne '' -and ((\"$cur $props $fp\".ToLower() -replace '[^a-z0-9]','') -match '{pat}')){{\"$cur`t$props`t$fp\"}};\+             $cur=$nm;$fp='';$props='';continue}};\+             if($line -match '\\(property\\s+\"([^\"]+)\"\\s+\"([^\"]*)\"'){{if($cur -eq ''){{continue}};\+             if($Matches[1] -eq 'Footprint'){{$fp=$Matches[2]}}else{{$props+=' '+$Matches[2]}}}}}};\+             if($cur -ne '' -and $fp -ne '' -and ((\"$cur $props $fp\".ToLower() -replace '[^a-z0-9]','') -match '{pat}')){{\"$cur`t$props`t$fp\"}}}}"))+    } else {+        let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");+        // FS='"' makes the quoted tokens fall out as fields: $2 = property/symbol+        // name, $4 = its value. Sub-unit symbols (`..._1_1`) are skipped so `cur`+        // stays on the top-level symbol that owns the Footprint property.+        format!(+            "awk -v pat='{pat}' 'BEGIN{{FS=\"\\\"\"}} \+             function key(s){{ s=tolower(s); gsub(/[^a-z0-9]/,\"\",s); return s }} \+             function flush(){{ if(cur!=\"\" && fp!=\"\" && key(cur \" \" props \" \" fp) ~ pat) print cur \"\\t\" props \"\\t\" fp }} \+             /^[ \\t]*\\(symbol[ \\t]+\"/ {{ nm=$2; if (nm ~ /_[0-9]+_[0-9]+$/) next; flush(); cur=nm; fp=\"\"; props=\"\"; next }} \+             /\\(property[ \\t]+\"/ {{ if(cur==\"\") next; if($2==\"Footprint\") fp=$4; else props=props \" \" substr($4,1,400); next }} \+             END{{ flush() }}' {list} 2>/dev/null")+    };+    let cmd = out;+    let mut rows = vec![];+    if let Some(text) = ad_run(&cmd) {+        for line in text.lines() {+            let mut it = line.splitn(3, '\t');+            if let (Some(n), Some(p), Some(f)) = (it.next(), it.next(), it.next()) {+                if !f.trim().is_empty() {+                    rows.push((n.trim().to_string(), p.trim().to_string(), f.trim().to_string()));+                }+            }+        }+    }+    rows+}+ /// Normalize a value/name for fuzzy compare: lowercase, µ→u, alphanumerics only. fn norm_val(s: &str) -> String {     s.to_lowercase().replace(['µ', 'μ'], "u").chars().filter(|c| c.is_ascii_alphanumeric()).collect()@@ -2445,9 +2951,9 @@ fn pkg_size(package: &str) -> String { }  /// Derive a (normalized-value, package) signature from a component's symbol name-/// and footprint name — the wiki symbols encode it (`C_10uF_0402`, footprint+/// and footprint name, the wiki symbols encode it (`C_10uF_0402`, footprint /// `C_0402_1005Metric`) even when the part JSON's value/package fields are blank.-/// None when there's no package code (e.g. an IC) — those match by MPN only.+/// None when there's no package code (e.g. an IC), those match by MPN only. fn value_package_sig(sym_name: &str, fp_name: &str) -> Option<(String, String)> {     let pkg = {         let p = pkg_size(fp_name);@@ -2489,8 +2995,8 @@ fn footprint_index(dirs: &[String]) -> std::collections::HashMap<String, String>             let lib = parent_dir(p).rsplit(['/', '\\']).next().unwrap_or("").trim_end_matches(".pretty").to_string();             if !fpname.is_empty() {                 idx.entry(norm_val(fpname)).or_insert_with(|| lib.clone());-                // also index the base part name (before the package suffix) — e.g.-                // "BMA580" from "BMA580_WLCSP-6" — so stub parts match by base-part.+                // also index the base part name (before the package suffix), e.g.+                // "BMA580" from "BMA580_WLCSP-6", so stub parts match by base-part.                 if let Some(base) = fpname.split('_').next() {                     if base != fpname && base.len() >= 3 {                         idx.entry(norm_val(base)).or_insert_with(|| lib.clone());@@ -2503,11 +3009,11 @@ fn footprint_index(dirs: &[String]) -> std::collections::HashMap<String, String> }  /// Which of the given component slugs are ALREADY in the user's libraries, with-/// the reason and the SYMBOL + FOOTPRINT library each is found in — so neither a+/// the reason and the SYMBOL + FOOTPRINT library each is found in, so neither a /// duplicate symbol nor a duplicate footprint gets added. Symbol match: MPN (grep /// or a lib symbol name that PREFIXES the MPN, covering stub parts), exact symbol /// name, else value + package. Footprint match: the `.kicad_mod` is present in a-/// `.pretty`. Returns (slug, reason, symLib, fpLib) — either lib may be empty.+/// `.pretty`. Returns (slug, reason, symLib, fpLib), either lib may be empty. fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)> {     let libs = user_libraries();     let uris = |key: &str| -> Vec<String> {@@ -2520,14 +3026,61 @@ fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)>     if (sym_files.is_empty() && fp_dirs.is_empty()) || slugs.is_empty() {         return vec![];     }+    // Load each part ONCE up front: the footprint scan below needs every part's symbol+    // NAME as a search key (a library symbol is named for its value/package and almost+    // never contains the MPN, so searching by MPN alone missed most of them).+    let loaded: Vec<Option<AdomLbrPart>> = slugs.iter().map(|s| load_part(s, &up(s)).ok().map(|(p, _)| p)).collect();     let mpn_map = mpns_present_in_libs(&sym_files, &slugs.iter().map(|s| up(s)).collect::<Vec<_>>());     let norm_index: std::collections::HashMap<String, String> =         existing_symbol_index(&sym_files).into_iter().map(|(n, l)| (norm_val(&n), l)).collect();     let fp_index = footprint_index(&fp_dirs);+    // what the user's OWN symbols declare as their footprint — the ground truth for a+    // part that is already in a library, including footprints that live in stock KiCad+    // libraries (which never appear in the personal .pretty scan above).+    let mut scan_keys: Vec<String> = slugs.iter().map(|s| up(s)).collect();+    for p in loaded.iter().flatten() {+        scan_keys.push(p.symbol.name.clone());+        scan_keys.push(p.footprint.name.clone());+    }+    let declared = symbol_declared_footprints(&sym_files, &scan_keys);+    // Find the user's own symbol for this part and read the footprint IT declares.+    // Matched, most specific first: the symbol's exact name, then the MPN appearing+    // anywhere in the symbol's properties (LCSC code, datasheet URL, …). Comparison is+    // alphanumeric-only so `R_1kR_0402_1%` and `R_1kR_0402_1` are the same symbol.+    let declared_for = |mpn: &str, sym_name: &str, fp_name: &str| -> String {+        let nm = norm_val(mpn);+        let sn = norm_val(sym_name);+        let fnm = norm_val(fp_name);+        // 1. the user's symbol with this exact name+        if sn.len() >= 3 {+            if let Some((_, _, f)) = declared.iter().find(|(n, _, _)| norm_val(n) == sn) {+                return f.clone();+            }+        }+        // 2. ANY of the user's symbols that uses the SAME FOOTPRINT. This is the+        //    strongest signal for "which library holds this footprint", and it does not+        //    depend on symbol naming at all — a page shipping `C_10uF_0402` still+        //    resolves against a library symbol called `C_10uF_6.3V_0402`, because both+        //    point at Capacitor_SMD:C_0402_1005Metric.+        if fnm.len() >= 4 {+            if let Some((_, _, f)) = declared.iter()+                .find(|(_, _, f)| norm_val(f.rsplit(':').next().unwrap_or(f)) == fnm)+            {+                return f.clone();+            }+        }+        // 3. the MPN appearing in a symbol's properties (LCSC code, datasheet URL, …)+        if nm.len() >= 4 {+            if let Some((_, _, f)) = declared.iter().find(|(n, p, _)| norm_val(&format!("{n} {p}")).contains(&nm)) {+                return f.clone();+            }+        }+        String::new()+    };     let mut out = vec![];-    for slug in slugs {+    for (si, slug) in slugs.iter().enumerate() {         let nmpn = norm_val(&up(slug));-        let part = load_part(slug, &up(slug)).ok().map(|(p, _)| p);+        let part = loaded.get(si).cloned().flatten();         // symbol library this part already lives in (if any) + why         let (reason, sym_lib): (&str, String) = if let Some(lib) = mpn_map.get(&up(slug).to_lowercase()) {             ("mpn", lib.clone())@@ -2548,11 +3101,19 @@ fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)>         } else {             ("", String::new())         };-        // footprint library this part's .kicad_mod already lives in (if any) —+        // footprint library this part's .kicad_mod already lives in (if any) -         // by exact footprint name, else a footprint base name that PREFIXES the MPN         let fp_lib = part.as_ref()             .and_then(|p| fp_index.get(&norm_val(&p.footprint.name)).cloned())             .or_else(|| fp_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())).map(|(_, l)| l.clone()))+            // nothing in the user's own .pretty folders: fall back to what the symbol+            // ALREADY in their library declares (`Resistor_SMD:R_0402…` -> `Resistor_SMD`).+            // Without this a green-checked part still showed no footprint library.+            .or_else(|| {+                let d = declared_for(&up(slug), part.as_ref().map(|p| p.symbol.name.as_str()).unwrap_or(""), part.as_ref().map(|p| p.footprint.name.as_str()).unwrap_or(""));+                let lib = d.split(':').next().unwrap_or("").trim().to_string();+                if lib.is_empty() { None } else { Some(lib) }+            })             .unwrap_or_default();         if !sym_lib.is_empty() || !fp_lib.is_empty() {             let reason = if sym_lib.is_empty() { "footprint" } else { reason };@@ -2571,7 +3132,7 @@ fn project_diff(board: &str, dest: &str) -> Value {     let s = sep();     let mut lib_path = format!("{}{s}{libnick}.kicad_sym", dest.trim_end_matches(['/', '\\']));     // if it's not at the given destination, search the user's Documents for it-    // (the default path guess can be wrong — e.g. "KiCad" vs "KiCAD")+    // (the default path guess can be wrong, e.g. "KiCad" vs "KiCAD")     if ad_file_exists(&lib_path) != Some(true) {         let (os, home) = machine();         let search = if os == "windows" {@@ -2608,7 +3169,7 @@ fn project_diff(board: &str, dest: &str) -> Value {         .unwrap_or_default();     let exists = !existing.is_empty() || ad_file_exists(&lib_path) == Some(true);     // The project library names its symbols the way the board's schematic does-    // (generic "C"/"R"/"BMA580"), and neither carries MPNs — so compare the library+    // (generic "C"/"R"/"BMA580"), and neither carries MPNs, so compare the library     // against the board's actual SCHEMATIC symbol set, not the components' names.     let sch_syms = board_schematic_symbols(board);     let slugs: Vec<String> = board_deps(board).get("deps").and_then(|d| d.as_array())@@ -2622,7 +3183,7 @@ fn project_diff(board: &str, dest: &str) -> Value {     json!({ "exists": exists, "add": add, "keep": keep, "remove": remove }) } -/// The set of symbol names (normalized) the board's schematic actually uses — the+/// The set of symbol names (normalized) the board's schematic actually uses, the /// name after the `lib_id "Lib:Name"` colon, across every `.kicad_sch` on the page. fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {     let mut syms = std::collections::HashSet::new();@@ -2641,15 +3202,23 @@ fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {     syms } -/// The actual work — converts + merges + delivers, updating PROGRESS at each step.-fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {+/// The actual work, converts + merges + delivers, updating PROGRESS at each step.+fn run_apply(items: Vec<(String, String, String, String)>, board: Option<String>, dest: String, project_only: bool) {     let _ = std::fs::create_dir_all(SCRATCH);     use std::collections::BTreeMap;     let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();     let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();-    for (slug, sym, fp) in &items {-        by_symlib.entry(sym.clone()).or_default().push(slug.clone());-        by_fplib.entry(fp.clone()).or_default().push(slug.clone());+    // In PROJECT-ONLY mode the board is delivered as a SELF-CONTAINED project — its own+    // <name>.kicad_sym + <name>.pretty live INSIDE the project folder (built by+    // fetch_project_files / selfcontain.py from the board's own files). So we must NOT+    // also merge its parts into the user's GLOBAL KiCad libraries. Leaving these maps+    // empty skips the global symbol + footprint delivery loops below entirely.+    if !project_only {+        for (slug, sym, fp, name) in &items {+            // carry the (optional) user-chosen symbol name alongside the slug+            by_symlib.entry(sym.clone()).or_default().push(format!("{slug}\u{1}{name}"));+            by_fplib.entry(fp.clone()).or_default().push(slug.clone());+        }     }     // resolve each library NAME to its real on-disk path from the user's lib tables;     // a name not in their tables (a brand-new library) falls back to the KiCad dir.@@ -2663,7 +3232,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:     let fp_paths = path_map("fpLibs");     // for "integrate into my libraries": index every symbol/footprint already in     // the user's libraries so an existing one is skipped instead of duplicated.-    // (Project-only mode builds a fresh isolated library — nothing to dedup.)+    // (Project-only mode builds a fresh isolated library, nothing to dedup.)     // which components are ALREADY in the user's libraries: match by MPN (which     // some library symbols embed), else by value + package. Skip those entirely     // (both symbol and footprint) so nothing gets duplicated.@@ -2680,13 +3249,23 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:      for (symlib, slugs) in &by_symlib {         let mut blocks: Vec<(String, String)> = vec![];-        for slug in slugs {+        for tagged in slugs {+            // "<slug>\u{1}<user-chosen name>" (name may be empty = keep the default)+            let (slug, want) = match tagged.split_once('\u{1}') {+                Some((s, n)) => (s, n.trim()),+                None => (tagged.as_str(), ""),+            };             done_n += 1;             prog(format!("Converting {slug} → {symlib}"), done_n);             match load_part(slug, &up(slug)) {                 Ok((part, _)) => {-                    if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {-                        blocks.push(b);+                    if let Some((name, block)) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {+                        // honour the name the user typed for this symbol in their library+                        if !want.is_empty() && want != name {+                            blocks.push((want.to_string(), rename_symbol_block(&block, &name, want)));+                        } else {+                            blocks.push((name, block));+                        }                     }                 }                 Err(e) => errors.push(format!("{slug}: {e}")),@@ -2696,20 +3275,20 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:         let remote = sym_paths.get(symlib).cloned().unwrap_or_else(|| format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep()));         let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());         // NEVER overwrite a library we failed to read. ad_pull returns None for-        // both "absent" and "pull failed" — only proceed with a fresh library+        // both "absent" and "pull failed", only proceed with a fresh library         // when the desktop confirms the file genuinely doesn't exist.         if existing.is_none() {             match ad_file_exists(&remote) {-                Some(false) => {} // genuinely absent — start a fresh library+                Some(false) => {} // genuinely absent, start a fresh library                 Some(true) => {                     errors.push(format!(-                        "{symlib}.kicad_sym exists on the desktop but could not be pulled — skipped to avoid overwriting it"+                        "{symlib}.kicad_sym exists on the desktop but could not be pulled, skipped to avoid overwriting it"                     ));                     continue;                 }                 None => {                     errors.push(format!(-                        "could not verify {symlib}.kicad_sym on the desktop (bridge error) — skipped to avoid overwriting it"+                        "could not verify {symlib}.kicad_sym on the desktop (bridge error), skipped to avoid overwriting it"                     ));                     continue;                 }@@ -2721,7 +3300,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:         let n_syms = |s: &str| s.matches("(symbol \"").count();         if let Some(ex) = &existing {             if n_syms(&merged) < n_syms(ex) {-                errors.push(format!("{symlib}.kicad_sym merge would shrink the library — aborted"));+                errors.push(format!("{symlib}.kicad_sym merge would shrink the library, aborted"));                 continue;             }         }@@ -2781,159 +3360,118 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: fn hdr(ct: &str) -> Header {     Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap() }+const VIEWER3D_HTML: &str = include_str!("viewer3d.html");+// Reverse-proxy (and cache) the wiki's PUBLIC adom-3d-viewer Babylon bundle so Project Manager can+// serve the canonical viewer SAME-ORIGIN at /v3d/. The assets are immutable (content-hashed+// chunks + a versioned entry), so an in-memory cache is safe and makes repeat opens instant.+static V3D_CACHE: Mutex<Option<std::collections::HashMap<String, (Vec<u8>, String)>>> = Mutex::new(None);+fn content_type_for(name: &str) -> &'static str {+    if name.ends_with(".js") || name.ends_with(".mjs") { "application/javascript" }+    else if name.ends_with(".wasm") { "application/wasm" }+    else if name.ends_with(".css") { "text/css" }+    else if name.ends_with(".json") || name.ends_with(".map") { "application/json" }+    else if name.ends_with(".png") { "image/png" }+    else if name.ends_with(".svg") { "image/svg+xml" }+    else { "application/octet-stream" }+}+fn v3d_asset(rel: &str) -> Response<Cursor<Vec<u8>>> {+    let rel = rel.split('?').next().unwrap_or(rel);+    if rel.is_empty() || rel.contains("..") {+        return Response::from_string("bad path").with_status_code(400);+    }+    // Content-hashed, immutable assets — let the browser cache them hard (the 12MB Babylon+    // chunk shouldn't re-download on every board open).+    let cache = || Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=604800, immutable"[..]).unwrap();+    if let Some(m) = V3D_CACHE.lock().unwrap().as_ref() {+        if let Some((b, ct)) = m.get(rel) {+            return Response::from_data(b.clone()).with_header(hdr(ct)).with_header(cache());+        }+    }+    let url = format!("{WIKI}/static/vendor/adom-3d-viewer-babylon9/{rel}");+    match curl(&url) {+        Some(bytes) => {+            let ct = content_type_for(rel).to_string();+            V3D_CACHE.lock().unwrap().get_or_insert_with(Default::default)+                .insert(rel.to_string(), (bytes.clone(), ct.clone()));+            Response::from_data(bytes).with_header(hdr(&ct)).with_header(cache())+        }+        None => Response::from_string("not found").with_status_code(404),+    }+} fn html(s: &str) -> Response<Cursor<Vec<u8>>> {-    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))+    // The UI is EMBEDDED in the binary, so the page changes on every app update while+    // its URL never does. With no cache directive the browser heuristically caches it+    // and keeps serving the old app after an update — new columns and fixes simply+    // don't appear, and only a hard reload helps. Never cache the shell.+    Response::from_string(s)+        .with_header(hdr("text/html; charset=utf-8"))+        .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store, must-revalidate"[..]).unwrap()) } fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {     Response::from_string(s).with_header(hdr("application/json")) } -const INDEX_HTML: &str = include_str!("manage.html");-const LOADER_GIF: &[u8] = include_bytes!("loader.gif");--#[cfg(test)]-mod glb_tests {-    use super::*;--    /// Assemble a minimal binary GLB from a glTF JSON value + BIN payload.-    fn build_glb(json: &Value, bin: &[u8]) -> Vec<u8> {-        let mut js = serde_json::to_vec(json).unwrap();-        while js.len() % 4 != 0 { js.push(b' '); }-        let mut bn = bin.to_vec();-        while bn.len() % 4 != 0 { bn.push(0); }-        let total = 12 + 8 + js.len() + 8 + bn.len();-        let mut out = Vec::new();-        out.extend_from_slice(b"glTF");-        out.extend_from_slice(&2u32.to_le_bytes());-        out.extend_from_slice(&(total as u32).to_le_bytes());-        out.extend_from_slice(&(js.len() as u32).to_le_bytes());-        out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes());-        out.extend_from_slice(&js);-        out.extend_from_slice(&(bn.len() as u32).to_le_bytes());-        out.extend_from_slice(&0x004E_4942u32.to_le_bytes());-        out.extend_from_slice(&bn);-        out-    }--    fn json_of(glb: &[u8]) -> Value {-        let jlen = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;-        serde_json::from_slice(&glb[20..20 + jlen]).unwrap()-    }--    // A Y-up scene: one root node (index 0) bearing a mesh.-    fn yup_glb(bin: &[u8]) -> Vec<u8> {-        let j = json!({-            "asset": {"version": "2.0"},-            "scenes": [{"nodes": [0]}],-            "scene": 0,-            "nodes": [{"mesh": 0, "name": "board"}],-            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],-        });-        build_glb(&j, bin)-    }--    #[test]-    fn stamps_and_rotates_raw_yup() {-        let bin = b"BINARYDATA-1234567890";-        let out = ensure_zup_stamp(&yup_glb(bin));-        // stamp present + readable by the same accessor APM uses-        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));-        let j = json_of(&out);-        let nodes = j["nodes"].as_array().unwrap();-        // exactly one adom_zup wrapper, rotating +90° about X, wrapping the old root-        let zup: Vec<_> = nodes.iter().filter(|n| n["name"] == "adom_zup").collect();-        assert_eq!(zup.len(), 1, "one wrapper node");-        let rot = zup[0]["rotation"].as_array().unwrap();-        assert!((rot[0].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);-        assert!((rot[3].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);-        assert_eq!(zup[0]["children"], json!([0]));-        // the scene now points at the wrapper, not the bare root-        let widx = nodes.iter().position(|n| n["name"] == "adom_zup").unwrap();-        assert_eq!(j["scenes"][0]["nodes"], json!([widx]));-        // BIN chunk survives verbatim-        assert!(out.windows(bin.len()).any(|w| w == bin), "bin preserved");-    }--    #[test]-    fn is_idempotent() {-        let once = ensure_zup_stamp(&yup_glb(b"payload-xyz"));-        let twice = ensure_zup_stamp(&once);-        assert_eq!(once, twice, "already-stamped input returned byte-identical");-    }--    #[test]-    fn rotated_but_unstamped_only_gets_stamp() {-        // Mimics the optimizer having rotated (adom_zup) but not stamped.-        let j = json!({-            "asset": {"version": "2.0"},-            "scenes": [{"nodes": [1]}],-            "scene": 0,-            "nodes": [-                {"mesh": 0, "name": "board"},-                {"name": "adom_zup", "rotation": [0.70710678, 0.0, 0.0, 0.70710678], "children": [0]}-            ],-            "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],-        });-        let out = ensure_zup_stamp(&build_glb(&j, b"bin"));-        assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));-        let j2 = json_of(&out);-        let zup = j2["nodes"].as_array().unwrap().iter().filter(|n| n["name"] == "adom_zup").count();-        assert_eq!(zup, 1, "no second rotation node added");-    }--    #[test]-    fn non_glb_passes_through() {-        let junk = b"not a glb at all".to_vec();-        assert_eq!(ensure_zup_stamp(&junk), junk);-    }--    fn obj(v: Value) -> serde_json::Map<String, Value> { v.as_object().unwrap().clone() }--    #[test]-    fn manifest_preserves_unknown_toplevel_keys() {-        // The exact regression from #218: a re-render must not drop `viewers`.-        let existing = obj(json!({-            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],-            "viewers": ["layout", "sch-usb"],-            "viewers_path": "viewers/{version}/",-            "versions": [{"version": "1.3.0", "hash": "aaa", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout"]}],-        }));-        // an unchanged re-render (same hash) still must not clobber unknown keys-        let m = merge_render_manifest(&existing, "1.3.0", "aaa", false, true, true, &["Root".into()]);-        assert_eq!(m["viewers"], json!(["layout", "sch-usb"]));-        assert_eq!(m["viewers_path"], json!("viewers/{version}/"));-        assert_eq!(m["versions"][0]["viewers"], json!(["layout"]), "per-version key survives");-    }--    #[test]-    fn manifest_new_version_merges_not_replaces() {-        // A changed render for a version string that already carries extra keys must-        // update owned keys yet keep the unknown ones on that entry.-        let existing = obj(json!({-            "v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],-            "viewers": ["layout"],-            "versions": [{"version": "1.4.0", "hash": "old", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout", "sch"]}],-        }));-        let m = merge_render_manifest(&existing, "1.4.0", "new", true, true, false, &["Root".into(), "Pwr".into()]);-        let v0 = &m["versions"][0];-        assert_eq!(v0["version"], json!("1.4.0"));-        assert_eq!(v0["hash"], json!("new"), "owned key updated");-        assert_eq!(v0["pcb"], json!(false), "owned key updated");-        assert_eq!(v0["sheets"], json!(["Root", "Pwr"]));-        assert_eq!(v0["viewers"], json!(["layout", "sch"]), "unknown per-version key preserved");-        assert_eq!(m["current"], json!("1.4.0"));-        assert_eq!(m["viewers"], json!(["layout"]), "top-level unknown key preserved");-        // exactly one entry for 1.4.0 (merged, not duplicated)-        let count = m["versions"].as_array().unwrap().iter()-            .filter(|v| v["version"] == json!("1.4.0")).count();-        assert_eq!(count, 1);-    }--    #[test]-    fn manifest_first_archive_from_empty() {-        let m = merge_render_manifest(&serde_json::Map::new(), "1.0.0", "h0", true, true, true, &["Root".into()]);-        assert_eq!(m["v"], json!(2));-        assert_eq!(m["current"], json!("1.0.0"));-        assert_eq!(m["versions"][0]["hash"], json!("h0"));+// ── embedded-tool launch + reverse proxy ──────────────────────────────────────+// Project Manager is the single entry point; each tool is a Node app it launches on demand+// and proxies under /tools/<slug>/ so it renders in an iframe on Project Manager's origin.+fn tool_spec(slug: &str) -> Option<(u16, &'static str)> {+    match slug {+        "klm" => Some((7823, "kicad-library-manager")),+        "cfl" => Some((7821, "chip-fetcher-lite")),+        _ => None,     } }+// Prefer the dep-installed copy (~/.claude/skills/<name>), fall back to the dev source.+fn tool_dir(name: &str) -> Option<std::path::PathBuf> {+    let home = std::env::var("HOME").ok()?;+    let installed = std::path::PathBuf::from(format!("{home}/.claude/skills/{name}"));+    if installed.join("server.js").exists() { return Some(installed); }+    let src = std::path::PathBuf::from(format!("/home/adom/project/{name}"));+    if src.join("server.js").exists() { return Some(src); }+    None+}+fn port_open(port: u16) -> bool {+    std::net::TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), std::time::Duration::from_millis(300)).is_ok()+}+// Ensure the tool's Node server is up (spawn `node server.js` with PORT set if not).+fn ensure_tool(slug: &str) -> Option<u16> {+    let (port, name) = tool_spec(slug)?;+    if port_open(port) { return Some(port); }+    let dir = tool_dir(name)?;+    let _ = Command::new("node").arg(dir.join("server.js"))+        .env("PORT", port.to_string()).current_dir(&dir)+        .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+        .spawn();+    for _ in 0..30 { if port_open(port) { return Some(port); } std::thread::sleep(std::time::Duration::from_millis(200)); }+    None+}+// Minimal HTTP/1.0 reverse proxy to the tool's localhost port. Handles binary+// bodies (GLB models); Connection:close means read-to-EOF gets the whole response.+fn proxy_tool(slug: &str, rest: &str, query: &str, method: &Method, body: &str) -> Response<Cursor<Vec<u8>>> {+    use std::io::Write;+    let port = match tool_spec(slug) { Some((p, _)) => p, None => return Response::from_string("{\"error\":\"unknown tool\"}").with_status_code(404).with_header(hdr("application/json")) };+    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));+    let mut stream = match std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(600)) {+        Ok(s) => s,+        Err(_) => return Response::from_string("{\"error\":\"tool not running\"}").with_status_code(502).with_header(hdr("application/json")),+    };+    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(60)));+    let path = if query.is_empty() { format!("/{rest}") } else { format!("/{rest}?{query}") };+    let m = match method { Method::Post => "POST", Method::Put => "PUT", Method::Delete => "DELETE", _ => "GET" };+    let req = format!("{m} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: */*\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());+    if stream.write_all(req.as_bytes()).is_err() { return Response::from_string("{\"error\":\"proxy write failed\"}").with_status_code(502).with_header(hdr("application/json")); }+    let mut buf = Vec::new();+    let _ = stream.read_to_end(&mut buf);+    let sep = buf.windows(4).position(|w| w == b"\r\n\r\n");+    let (head, bodyb): (&[u8], &[u8]) = match sep { Some(i) => (&buf[..i], &buf[i + 4..]), None => (&buf[..], &[][..]) };+    let head_str = String::from_utf8_lossy(head);+    let code = head_str.lines().next().and_then(|l| l.split_whitespace().nth(1)).and_then(|c| c.parse::<u16>().ok()).unwrap_or(200);+    let ct = head_str.lines().find(|l| l.to_ascii_lowercase().starts_with("content-type:"))+        .and_then(|l| l.split_once(':')).map(|(_, v)| v.trim().to_string())+        .unwrap_or_else(|| "application/octet-stream".into());+    Response::from_data(bodyb.to_vec()).with_status_code(code).with_header(hdr(&ct))+}++// v0.9.5: board 3D routes wiki adom-3d-viewer (public) vs server-proxied fallback (private).+const INDEX_HTML: &str = include_str!("manage.html");+const LOADER_GIF: &[u8] = include_bytes!("loader.gif");
src/manage.html+1002−167
@@ -6,25 +6,162 @@ <title>Adom Project Manager</title> <script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style>-  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}+  :root{--bg:#0d1117;--panel:#161b22;--elevated:#1c2128;--overlay:#21262d;--edge:#30363d;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b0;--acc2:#00e6dc;--green:#3fb950;--red:#f85149;--yellow:#d29922}   *{box-sizing:border-box}-  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+  body{margin:0;font:14px/1.5 'Satoshi',-apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}+  /* canonical Adom 44px three-zone app header (brand · subject · actions) */+  header.app{height:44px;flex:0 0 44px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;+             gap:12px;padding:0 14px;background:var(--panel);border-bottom:1px solid var(--edge);position:relative;z-index:30}+  header.app .brand{display:flex;align-items:center;gap:8px;min-width:0;position:relative;cursor:default}+  header.app .brand svg{width:16px;height:16px;color:var(--ink);flex:0 0 16px}+  header.app .app-name{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:13px;color:var(--acc);white-space:nowrap}+  header.app .app-sub{color:var(--mut);font-size:12px;white-space:nowrap}+  header.app .app-sub::before{content:"·";margin:0 6px;color:var(--mut)}+  header.app .brand{padding-right:14px;border-right:1px solid var(--edge)}+  header.app .subject{justify-self:center;display:flex;align-items:baseline;gap:10px;min-width:0}+  header.app .subject-name{font-weight:600;font-size:13px;max-width:44vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+  header.app .subject-meta{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11px;color:var(--mut);white-space:nowrap}+  header.app .actions{justify-self:end;display:flex;align-items:center;gap:8px}+  /* app-info popover on brand hover */+  header.app .appinfo{position:absolute;top:calc(100% + 6px);left:0;min-width:220px;background:var(--overlay);border:1px solid var(--edge);+                      border-radius:10px;box-shadow:0 12px 34px #000a;padding:12px 14px;font-size:12px;opacity:0;visibility:hidden;+                      transition:opacity .18s ease;pointer-events:none;z-index:40}+  header.app .brand:hover .appinfo{opacity:1;visibility:visible;pointer-events:auto;transition-delay:.6s}+  header.app .appinfo .ai-row{display:flex;justify-content:space-between;gap:16px;padding:2px 0}+  header.app .appinfo .ai-k{color:var(--mut)}+  header.app .appinfo .ai-v{font-family:'JetBrains Mono',ui-monospace,monospace}+  header.app .appinfo a{color:var(--acc);text-decoration:none}+  header.app .appinfo a:hover{text-decoration:underline}+  #workspace{flex:1;min-height:0;display:flex}+  /* ── HOME gallery: the full-screen board wall (top level). The left list + view+       only render once you click into a project. ── */+  #home{flex:1;min-height:0;display:none;flex-direction:column;background:var(--bg)}+  #workspace.home #home{display:flex}+  #workspace.home #list,#workspace.home #view{display:none}+  .home-bar{display:flex;align-items:center;gap:16px;padding:16px 22px 12px;flex-wrap:wrap;max-width:1240px;margin:0 auto;width:100%}+  .seg{display:inline-flex;background:var(--elevated);border:1px solid var(--edge);border-radius:9px;padding:3px;gap:2px}+  .seg .segbtn{background:none;border:none;color:var(--mut);font:inherit;font-size:12.5px;font-weight:500;padding:6px 15px;border-radius:6px;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:7px;transition:color .12s,background .12s}+  .seg .segbtn:hover{color:var(--ink)}+  .seg .segbtn.active{background:var(--acc);color:#052220;font-weight:600}+  .seg .segn{font-size:11px;opacity:.75;font-variant-numeric:tabular-nums}+  .seg .segbtn.active .segn{opacity:.85}+  .home-tools{margin-left:auto;display:flex;align-items:center;gap:12px;flex-wrap:wrap}+  .home-search-wrap{position:relative;display:flex;align-items:center}+  .home-search-wrap .hs-ico{position:absolute;left:11px;color:var(--mut);pointer-events:none}+  .home-search{background:#0d0f14;border:1px solid var(--edge);border-radius:9px;color:var(--ink);padding:8px 12px 8px 31px;font:inherit;font-size:13px;width:230px;transition:border-color .15s,width .18s}+  .home-search::placeholder{color:var(--mut)}+  .home-search:focus{outline:none;border-color:var(--acc);width:266px}+  .home-sort{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:7px;white-space:nowrap}+  .home-sort select{background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:8px;font-size:12px;padding:7px 26px 7px 10px;font-family:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%238b93a3' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 9px center}+  .home-sort select:focus{outline:none;border-color:var(--acc)}+  .home-chips{display:flex;flex-wrap:wrap;gap:6px;padding:0 22px 12px;max-width:1240px;margin:0 auto;width:100%}+  /* Clear breathing room between the filter chips and the first row/section header. */+  #home-scroll{flex:1;min-height:0;overflow:auto;padding:14px 22px 34px}+  /* Fixed 3-wide wall. A centered max-width keeps cards a consistent (bigger) size once+     the window is wide enough — past ~1240px the grid stops growing instead of stretching.+     Narrower viewports gracefully drop to 2 then 1 column. */+  .gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28px;align-content:start;max-width:1240px;margin:0 auto}+  @media (max-width:920px){ .gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px} }+  @media (max-width:600px){ .gallery{grid-template-columns:1fr;gap:18px} }+  .g-sec-head{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin:22px 2px 3px;font-size:12px;font-weight:700;color:var(--text-2,#8b949e);text-transform:uppercase;letter-spacing:.06em}+  .g-sec-head:first-child{margin-top:8px}+  .g-sec-head .gsh-avatar{width:22px;height:22px;border-radius:6px;object-fit:cover;background:#1a1f2b}+  .g-sec-head .gsh-count{color:#5b6472;font-weight:600}+  .g-sec-head .gsh-main{display:inline-flex;align-items:center;gap:10px;min-width:0}+  .g-sec-head .gsh-main.link{cursor:pointer}+  .g-sec-head .gsh-main.link:hover{color:var(--ink)}+  .g-sec-head .gsh-main.link:hover .gsh-label{color:var(--acc);text-decoration:underline}+  .g-sec-head .gsh-chev{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;background:none;border:none;border-radius:5px;cursor:pointer;color:var(--mut);transition:transform .15s ease,background .12s,color .12s}+  .g-sec-head .gsh-chev:hover{background:var(--elevated);color:var(--acc)}+  .g-sec-head .gsh-chev.coll{transform:rotate(-90deg)}+  .g-sec-head .gsh-eng{margin-left:6px;font-size:11px;font-weight:700;color:#f0a81c;background:#463307;border-radius:5px;padding:1px 7px;letter-spacing:0;font-variant-numeric:tabular-nums}+  .gcard{position:relative;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;cursor:pointer;display:flex;flex-direction:column;transition:border-color .15s,transform .15s,box-shadow .15s}+  .gcard::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--kc,#79b8ff);z-index:3;opacity:.9}+  .gcard:hover{transform:translateY(-3px);border-color:var(--kc,var(--acc));box-shadow:0 14px 32px #0009}+  .gcard .g-thumb{position:relative;aspect-ratio:16/10;background:#0d0f14;overflow:hidden}+  .gcard .g-hero{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover;transition:transform .35s ease;will-change:transform}+  .gcard:hover .g-hero{transform:scale(1.07)}+  .gcard .g-ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'Familjen Grotesk',sans-serif;font-weight:800;font-size:40px;color:#ffffff26;background:linear-gradient(140deg,color-mix(in srgb,var(--kc,#2a3340) 32%,#0d0f14) 0%,#0d0f14 72%)}+  .gcard .g-star{position:absolute;top:8px;right:9px;z-index:4;color:#f5c518;font-size:14px;filter:drop-shadow(0 1px 2px #000c)}+  .gcard .g-body{padding:11px 13px 12px;display:flex;flex-direction:column;gap:8px;flex:1;min-width:0}+  .gcard .g-title{font-weight:600;font-size:13.5px;line-height:1.3;color:var(--ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}+  .gcard .g-meta{display:flex;flex-wrap:wrap;gap:5px;align-items:center}+  .gcard .tag{display:inline-block;font-size:10px;border-radius:4px;padding:2px 6px;font-weight:600;line-height:1.3}+  .gcard .g-sub{display:flex;align-items:flex-end;gap:8px;margin-top:auto}+  .gcard .g-owner{font-size:11px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1}+  .gcard .g-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex:none}+  .gcard .g-eng{font-size:11px;font-weight:700;color:#f0a81c;font-variant-numeric:tabular-nums;line-height:1.1}+  .gcard .g-owner.link{cursor:pointer}+  .gcard .g-owner.link:hover{color:var(--acc);text-decoration:underline}+  .g-userbanner{align-items:center}+  .g-userbanner svg{color:var(--acc)}+  .g-userbanner .g-clearuser{margin-left:8px;background:var(--elevated);border:1px solid var(--edge);color:var(--mut);width:22px;height:22px;border-radius:6px;cursor:pointer;font-size:12px;line-height:1;display:inline-flex;align-items:center;justify-content:center;padding:0}+  .g-userbanner .g-clearuser:hover{border-color:var(--acc);color:var(--ink)}+  .gcard .g-when{font-size:10.5px;color:#6d7686;white-space:nowrap;flex:none;font-variant-numeric:tabular-nums}+  .g-empty{grid-column:1/-1;color:#8b93a3;text-align:center;padding:64px 20px;line-height:1.7}+  @media (max-width:600px){ .home-bar{padding:12px 14px 8px} .home-tools{width:100%;margin-left:0} .home-search,.home-search:focus{width:100%;flex:1} .home-chips,#home-scroll{padding-left:14px;padding-right:14px} }+  /* ── Tools menu (top-right): Chip Fetcher + Library Manager(EDA) ── */+  .tools-wrap{position:relative}+  /* "update available" badge — hidden until the update check confirms a newer wiki version */+  #update-badge{display:none;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:7px;font-size:12px;font-weight:600;text-decoration:none;background:#3a2e12;color:#e5b567;border:1px solid #6b501a;white-space:nowrap}+  #update-badge.show{display:inline-flex}+  #update-badge:hover{border-color:#d29922;color:#f0c674;background:#463813}+  #tools-btn{display:flex;align-items:center;gap:6px;background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:7px;height:26px;padding:0 10px;font-size:12px;cursor:pointer;font-family:inherit}+  #tools-btn:hover,#tools-btn.open{border-color:var(--acc);color:var(--acc)}+  #tools-menu{position:absolute;top:calc(100% + 6px);right:0;min-width:186px;background:var(--overlay);border:1px solid var(--edge);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;display:none;z-index:60}+  #tools-menu.open{display:block}+  .menu-item{display:flex;align-items:center;gap:9px;width:100%;background:none;border:none;color:var(--ink);font:inherit;font-size:13px;text-align:left;padding:7px 9px;border-radius:7px;cursor:pointer;white-space:nowrap}+  .menu-item:hover:not(:disabled){background:var(--elevated)}+  .menu-item svg{flex:0 0 15px;color:var(--mut)}+  .menu-item:hover:not(:disabled) svg{color:var(--acc)}+  .menu-item .chev{margin-left:auto}+  .menu-sub{position:relative}+  .menu-flyout{position:absolute;top:-5px;right:calc(100% + 4px);min-width:150px;background:var(--overlay);border:1px solid var(--edge);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;display:none}+  .menu-sub:hover .menu-flyout,.menu-flyout.open{display:block}+  .menu-item.soon{cursor:default;color:var(--mut)}+  .menu-item.soon em{margin-left:auto;font-style:normal;font-size:10px;color:var(--yellow);background:#2a2109;border:1px solid #4a380e;border-radius:5px;padding:1px 6px}+  /* full-screen embedded-tool view */+  #tool-view{position:fixed;inset:0;z-index:200;background:var(--bg);display:none;flex-direction:column}+  #tool-view.open{display:flex}+  #tool-view .tv-head{height:44px;flex:0 0 44px;display:flex;align-items:center;gap:10px;padding:0 14px;background:var(--panel);border-bottom:1px solid var(--edge)}+  #tool-view .tv-back{display:flex;align-items:center;gap:7px;background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:7px;height:28px;padding:0 11px;font:inherit;font-size:12px;cursor:pointer}+  #tool-view .tv-back:hover{border-color:var(--acc);color:var(--acc)}+  #tool-view .tv-title{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:13px;color:var(--acc)}+  #tool-view .tv-sub{color:var(--mut);font-size:12px}+  #tool-view .tv-body{flex:1;min-height:0;position:relative}+  #tool-view iframe{position:absolute;inset:0;width:100%;height:100%;border:0}+  #tool-view .tv-load{position:absolute;inset:0;display:grid;place-items:center;color:var(--mut);font-size:13px;gap:10px}+  /* toasts: transient feedback (bottom-center), also the AI's live narration channel */+  #toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3000;display:flex;flex-direction:column-reverse;gap:8px;align-items:center;pointer-events:none}+  .toast{pointer-events:auto;min-width:220px;max-width:min(440px,90vw);display:flex;align-items:center;gap:10px;padding:10px 13px;border-radius:10px;+         background:var(--overlay);border:1px solid var(--edge);color:var(--ink);font-size:13px;box-shadow:0 10px 30px #000a;cursor:pointer;+         animation:toastin .18s ease}+  @keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}+  .toast.out{opacity:0;transform:translateY(8px);transition:opacity .2s ease,transform .2s ease}+  .toast .ti{flex:0 0 16px;width:16px;height:16px}+  .toast.success .ti{color:var(--green)} .toast.success{border-color:#2c5a3a}+  .toast.error .ti{color:var(--red)} .toast.error{border-color:#5a2c2c}+  .toast.info .ti{color:var(--acc)}+  .toast .tmsg{flex:1;min-width:0}   #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}-  #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}+  #list h1{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}   #list h1 small{color:var(--mut);font-weight:400}-  #refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}-  #refresh:hover{background:#243049;border-color:var(--acc)}-  #crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}+  #refresh{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}+  #refresh:hover{background:#0c2b29;border-color:var(--acc)}+  #crumb{padding:10px 16px 0;font-size:12px;color:var(--mut)}+  #crumb:empty{display:none}   #crumb a{color:var(--acc);text-decoration:none}   #crumb a:hover{text-decoration:underline}   #crumb .cactive{color:var(--ink);font-weight:600}-  .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}-  .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}+  .row .tag{display:inline-block;font-size:10px;background:#0c2b29;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+  .tag{display:inline-block;font-size:10px;background:#0c2b29;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}   .row .row-tags{margin-top:4px}   .row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}   .tag.kind{text-transform:capitalize}   .tag.kind.board{background:#16324f;color:#79b8ff}   .tag.kind.molecule{background:#463307;color:#f0a81c}+  .tag.kind.circuit{background:#3d1a2e;color:#f472b6}+  .tag.kind.library{background:#2b2350;color:#b9a4ff}   .tag.kind.project{background:#0e3b36;color:#2dd4bf}   .tag.eda:empty{display:none}   .tag.eda.kicad{background:#0b2036;color:#5c9ce6}@@ -34,12 +171,30 @@   .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}+  .sortrow{display:flex;justify-content:flex-end;align-items:center;padding:0 16px 8px}+  .sortrow label{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--mut)}+  .sortrow select{background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:6px;font-size:11px;padding:3px 22px 3px 7px;font-family:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%238b93a3' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 7px center}+  .sortrow select:hover{border-color:var(--acc)}+  /* board-specific library expander on a board row */+  /* subtle disclosure on its OWN line below the tags — not a loud pill inline with+     them; scoped under .row so it always beats the public-page button rule */+  .row button.libtoggle{margin-top:7px;display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:500;color:var(--mut);background:transparent;border:1px solid var(--edge);border-radius:6px;padding:3px 9px;cursor:pointer;font-family:inherit;line-height:1.2}+  .row button.libtoggle:hover,.row button.libtoggle.open{border-color:var(--acc);color:var(--ink);background:transparent;filter:none}+  .row button.libtoggle .cv{display:inline-block;transition:transform .15s;font-size:8px;line-height:1}+  .row button.libtoggle.open .cv{transform:rotate(180deg)}+  .libchild{background:#0d0f14;border-bottom:1px solid var(--edge)}+  .libchild-row{padding:8px 16px 8px 30px;border-bottom:1px solid var(--edge);cursor:pointer}+  .libchild-row:last-child{border-bottom:none}+  .libchild-row:hover{background:var(--elevated)}+  .libchild-row .mpn{font-weight:600;font-size:13px}   .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.project{background:#0e3b36;color:#2dd4bf}   .fchip.on.molecule{background:#463307;color:#f0a81c}   .fchip.on.board{background:#16324f;color:#79b8ff}+  .fchip.on.circuit{background:#3d1a2e;color:#f472b6}+  .fchip.on.library{background:#2b2350;color:#b9a4ff}   .fchip.on.kicad{background:#0b2036;color:#5c9ce6}   .fchip.on.altium{background:#3d3416;color:#f5c518}   .fchip.on.fusion{background:#452510;color:#ff8c33}@@ -49,9 +204,13 @@   .orgrow{display:flex;align-items:center;gap:11px}   .orgrow .orgicon{width:36px;height:36px;flex:none;color:#2dd4bf;opacity:.9;padding:6px;box-sizing:border-box;background:#1a1f2b;border-radius:8px}   .orgrow .orgicon-img{width:36px;height:36px;flex:none;border-radius:8px;object-fit:cover;background:#1a1f2b}-  .orgrow .orgname{font-weight:600;color:var(--ink)}-  .orgrow .orgmeta{color:var(--mut);font-size:12.5px}-  .orgrow .scorebadge{margin-left:0}+  /* Right-justified two-line block so a long org name (e.g. "ntx embedded") no+     longer crowds the score badge off the row; mirrors the trending board rows. */+  .orgrow .orgtext{margin-left:auto;min-width:0;text-align:right}+  .orgrow .orgtop{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}+  .orgrow .orgname{font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+  .orgrow .orgmeta{color:var(--mut);font-size:12.5px;margin-top:2px}+  .orgrow .scorebadge{margin-left:0;flex:none}   .scorebadge{font-size:10px;font-weight:700;color:#f0a81c;background:#463307;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px}   .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}@@ -65,64 +224,88 @@   .pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}   .pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}   .pubsearch input:focus{outline:none;border-color:var(--acc)}-  .pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}-  .pubsearch button:hover{filter:brightness(1.1)}+  .pubsearch .pubrow button{background:var(--acc);color:#052220;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}+  .pubsearch .pubrow button:hover{filter:brightness(1.1)}   .row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}   #toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}   #toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}   #toolbar .tbtns{display:flex;gap:7px}   #toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}-  #toolbar button:hover{border-color:var(--acc);background:#22304a}-  #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}+  #toolbar button:hover{border-color:var(--acc);background:#123b39}+  #toolbar button.primary{background:var(--acc);color:#052220;border-color:var(--acc);font-weight:600}   #toolbar button:disabled{opacity:.45;cursor:not-allowed}+  /* unavailable toolbar buttons: greyed like a disabled button but still hoverable so+     the Adom tooltip (#tip via data-tip) can explain why. */+  #toolbar button.off{opacity:.45;cursor:not-allowed}+  #toolbar button.off:hover{border-color:var(--edge);background:#1a2130}+  .bomrow .bom-qty{color:var(--acc);font-weight:700;margin-right:6px}+  .bomrow .bom-refs{color:var(--mut);font-weight:400;font-size:11px;font-family:'JetBrains Mono',ui-monospace,monospace}+  .bomrow.nolink{cursor:default}   .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{font-family:'Familjen Grotesk',-apple-system,sans-serif;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}+  /* wide: the plan table carries five columns (component, symbol name, type, and+     two library pickers) and was cramped at the old 760px */+  .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(1180px,95vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}+  .symname{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;width:100%;min-width:150px}+  .symname:focus{outline:none;border-color:var(--acc)}+  .symname.edited{border-color:var(--acc);color:var(--acc)}+  .modal h2{font-family:'Familjen Grotesk',-apple-system,sans-serif;margin:0 0 4px;font-size:18px}   .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}   .modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}   .modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}-  .modal .opt.sel{border-color:var(--acc);background:#1a2740}+  .modal .opt.sel{border-color:var(--acc);background:#0e2e2c}   .modal table{width:100%;margin:0 0 6px}   .modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}   .modal-actions{display:flex;gap:10px;justify-content:flex-end}   .modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}-  .modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}-  .modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}+  .modal-actions button.primary{background:var(--acc);color:#052220;border-color:var(--acc);font-weight:700}+  .modal .lib{font:12px 'JetBrains Mono',ui-monospace,monospace;color:#7ee0c0}   .modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}   #modal-dest{margin:0 0 16px}   #modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}   #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{flex:2;text-align:left;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px 'JetBrains Mono',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{display:block;width:100%;box-sizing:border-box;padding:9px 16px;font:12px 'JetBrains Mono',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-ico{vertical-align:-2px;margin-right:5px;color:var(--mut)}   .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}+  .fb-pick{background:var(--acc)!important;border-color:var(--acc)!important;color:#052220!important;font-weight:600}   #modal-dest #destname{flex:1}-  #modal-dest .destsep{color:var(--mut);font:14px ui-monospace,monospace;flex:0 0 auto}+  #modal-dest .destsep{color:var(--mut);font:14px 'JetBrains Mono',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%}   .libsel:focus{outline:none;border-color:var(--acc)}   .libsel option{background:#262c36;color:var(--ink)}+  /* a row still needing a choice (amber) vs no libraries detected at all (dimmed) */+  .libsel.unset{border-color:#f0a81c;color:#f0a81c}+  .libsel.nolibs{opacity:.6;font-style:italic}+  /* KiCad's own bundled library, not one of the user's — blue, so it reads as+     "this came with KiCad" rather than the amber "you still have to choose" */+  .libsel.stock{border-color:#5aa9e6;color:#9ad0f5;background:#5aa9e614}+  .lib.stock{border-color:#5aa9e6;color:#9ad0f5;background:#5aa9e614}+  .stocktag{color:#9ad0f5}+  .stocktag::after{content:' (KiCad)';opacity:.75;font-size:10px}+  .bulkset{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin:0 0 8px;padding:7px 10px;border:1px solid var(--edge);border-radius:8px;background:#262c3688;font-size:12px;color:var(--mut)}+  .bulkitem{display:inline-flex;gap:6px;align-items:center}   .inlib td{color:#4ade80}   .inlib .lib{color:#4ade80}   .inlibtag{display:block;margin-top:2px;color:#4ade80;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}@@ -138,7 +321,7 @@   @keyframes barslide{to{left:100%}}   #modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}   #modal-dest select{min-width:200px}-  #modal-dest input{flex:1;font:12px ui-monospace,monospace}+  #modal-dest input{flex:1;font:12px 'JetBrains Mono',ui-monospace,monospace}   #modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}   #refresh.spin{animation:sp .6s linear infinite}   @keyframes sp{to{transform:rotate(360deg)}}@@ -146,13 +329,13 @@   #rows{overflow:auto;flex:1}   .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}   .row:hover{background:#1e222b}-  .row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}+  .row.sel{background:#0c2b29;border-left:3px solid var(--acc);padding-left:13px}   .row .mpn{font-weight:600}-  .row .sub{color:var(--mut);font-size:12px}+  .row .sub{color:var(--mut);font-size:12px;font-family:'JetBrains Mono','JetBrains Mono',ui-monospace,monospace}   #view{flex:1;overflow:hidden;padding:0;display:flex;flex-direction:column;min-width:0;position:relative}   #cv-close{display:none;position:absolute;right:14px;top:calc(50% + 10px);z-index:6;background:#1a2130;border:1px solid var(--edge);color:var(--ink);width:27px;height:27px;border-radius:6px;cursor:pointer;font-size:14px;line-height:1}   #view.split #cv-close{display:block}-  #cv-close:hover{border-color:var(--acc);background:#22304a}+  #cv-close:hover{border-color:var(--acc);background:#123b39}   #board-overview{flex:1;overflow:auto;min-height:0}   #component-view{display:none;min-height:0;overflow:auto;padding:16px}   #view.split #board-overview{flex:0 0 50%}@@ -161,8 +344,12 @@   /* 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}+  /* empty component-dependency list is an ERROR the maintainer should fix (its BOM+     and library can't resolve), so it reads as a red bar, not a muted gray note */+  .deps-none{background:#3a1214;border:1px solid #7f1d1d;color:#fca5a5;border-radius:8px;padding:10px 13px;font-size:13px;line-height:1.45;font-weight:500;cursor:default}+  .deps-none 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-head .title{font-family:'Familjen Grotesk',-apple-system,sans-serif;margin:0;font-size:20px;font-weight:700}   .bo-author{color:var(--mut);font-size:13px;margin-top:3px}   .bo-hero{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto;border-radius:8px}   .bo-desc{color:var(--ink);font-size:13px;line-height:1.55}@@ -174,7 +361,7 @@   .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:hover{background:#0c2b29}   .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)}@@ -206,8 +393,6 @@   .bo-viewbody{height:260px;background:#161b22;border:1px solid var(--edge);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;touch-action:none}   .bo-viewbody svg{width:100%;height:100%;display:block}   .bo-na{color:#8b93a3;font-size:13px;text-align:center;padding:20px;line-height:1.5}-  /* self-contained interactive board/schematic viewer (adom-layout-viewer embed) */-  .bo-viewer{width:100%;height:100%;border:0;display:block;background:#0d1117}   /* In-viewer loading spinner (3D / schematic / PCB): Adom mark + percent below */   .vload{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;width:100%;gap:10px;background:#0d0f14}   .vload.ov{position:absolute;inset:0;z-index:5}@@ -223,7 +408,7 @@   .chg{font-size:10px;padding:1px 7px;border-radius:4px;background:#21262e;color:var(--mut);white-space:nowrap}   .chg.add{color:#4ade80}   .chg.rm{color:#f87171}-  .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}+  .commit-hash{font-family:'JetBrains Mono',ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}   .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}   .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}   .card .cardfs{position:absolute;top:7px;right:9px;z-index:4}@@ -252,14 +437,15 @@   th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}   tr:last-child td{border-bottom:none}   .swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}-  .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}+  .badge{font:12px 'JetBrains Mono',ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}   .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}   .err{color:#f87171;padding:16px}   .card .body [data-num]{cursor:pointer}   .pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}   rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}-  #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}+  #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px 'JetBrains Mono',ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}   #tip b{color:var(--acc)}+  .bo-embed{width:100%;height:100%;border:0;display:block} </style> </head> <body>@@ -268,16 +454,92 @@     <div class="lt">Adom <span>Project Manager</span></div>     <div class="lp">Fetching Projects: <b id="app-loader-pct">0%</b></div>   </div>+  <header class="app">+    <div class="brand">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2.1" fill="currentColor" stroke="none"/><ellipse cx="12" cy="12" rx="10" ry="4.3"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(120 12 12)"/></svg>+      <span class="app-name">Project Manager</span><span class="app-sub">Adom&#39;s Electronics Development Suite</span>+      <div class="appinfo">+        <div class="ai-row"><span class="ai-k">version</span><span class="ai-v" id="ai-ver">-</span></div>+        <div class="ai-row"><span class="ai-k">built on</span><span class="ai-v">adom-lbr</span></div>+        <div class="ai-row"><span class="ai-k">source</span><a href="https://wiki.adom.inc/adom/adom-project-manager" target="_blank" rel="noopener">wiki page ↗</a></div>+      </div>+    </div>    <div class="subject"><span class="subject-name" id="hdr-name"></span><span class="subject-meta" id="hdr-meta"></span></div>+    <div class="actions">+      <a id="update-badge" href="https://wiki.adom.inc/adom/adom-project-manager" target="_blank" rel="noopener" title="A newer version is on the wiki">+        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>+        <span id="update-badge-txt">Update available</span>+      </a>+      <div class="tools-wrap">+        <button id="tools-btn" title="Tools — Chip Fetcher Lite, Library Manager">+          <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6z"/></svg>+          <span>Tools</span>+        </button>+        <div id="tools-menu" class="menu">+          <button class="menu-item" data-tool="cfl">+            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="7" rx="1"/><path d="M9 10v3M15 10v3M6 13h12l-1 8H7z"/></svg>+            <span>Chip Fetcher Lite</span>+          </button>+          <div class="menu-sub">+            <button class="menu-item" id="lm-parent">+              <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h6l2 2h8v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/></svg>+              <span>Library Manager</span>+              <svg class="chev" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>+            </button>+            <div class="menu-flyout">+              <button class="menu-item" data-tool="klm"><span>KiCad</span></button>+              <button class="menu-item soon" disabled><span>Altium</span><em>coming soon</em></button>+              <button class="menu-item soon" disabled><span>Fusion</span><em>coming soon</em></button>+            </div>+          </div>+        </div>+      </div>+      <button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button>+    </div>+  </header>+  <div id="toasts"></div>+  <!-- full-screen embedded tool (Project Manager launches the Node tool + reverse-proxies it) -->+  <div id="tool-view">+    <div class="tv-head">+      <button class="tv-back" onclick="closeTool()"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>Project Manager</button>+      <span class="tv-title" id="tv-title"></span><span class="tv-sub" id="tv-sub"></span>+    </div>+    <div class="tv-body"><div class="tv-load" id="tv-load"><img src="loader.gif" width="48" alt=""><span>Starting tool…</span></div><iframe id="tv-frame" title="tool"></iframe></div>+  </div>+  <div id="workspace">+  <!-- HOME: full-screen gallery of boards/molecules/projects/circuits. The left list+       only appears once you click into a project (the interior below). -->+  <section id="home">+    <div class="home-bar">+      <div id="home-tabs" class="seg"></div>+      <div class="home-tools">+        <div class="home-search-wrap"><svg class="hs-ico" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></svg><input id="home-q" class="home-search" placeholder="search projects…"></div>+        <label class="home-sort">Sort <select id="home-sort-sel">+          <option value="recent">Most recent</option>+          <option value="alpha">Alphabetical</option>+          <option value="tag">Tag</option>+          <option value="eda">EDA</option>+          <option value="score">Engagement</option>+        </select></label>+      </div>+    </div>+    <div id="home-chips" class="tagfilter home-chips"></div>+    <div id="home-scroll"><div id="home-grid" class="gallery"></div></div>+  </section>   <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="sortrow" class="sortrow"><label>Sort <select id="sortby">+      <option value="alpha">Alphabetical</option>+      <option value="tag">Tag</option>+      <option value="eda">EDA</option>+      <option value="score">Engagement</option>+    </select></label></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">-        <button id="btn-addlib" disabled>Add to Library</button>+        <button id="btn-addlib" class="off">Add to Library</button>         <button id="btn-addproj">Add Project ↓</button>       </div>     </div>@@ -288,6 +550,7 @@     <div id="board-overview"><div id="hint">← pick a board to see it here</div></div>     <div id="component-view"></div>   </main>+  </div>   <div id="modal" class="modal-overlay" style="display:none"><div class="modal">     <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>     <div id="modal-status" class="libstatus"></div>@@ -302,7 +565,73 @@ const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'}; // Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component // dependencies ▸ the symbol/footprint/3D viewer.-let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';+let all=[], bySlug={}, boardSlug=null, curDeps=[], curBom=[], me='', pubQuery='', orgs=[], boardTab='mine';+// canonical header subject zone: the currently-open board + its meta+function setSubject(name, slug){+  const n=document.getElementById('hdr-name'), m=document.getElementById('hdr-meta');+  if(n) n.textContent=name||'';+  if(m) m.textContent=slug&&slug!==name?slug:'';+}+function setSubjectMeta(txt){ const m=document.getElementById('hdr-meta'); if(m&&boardSlug) m.textContent=txt||''; }+// ── toasts: every action answers back; also the AI's live narration channel ──+const TOAST_ICONS={+  success:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',+  error:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',+  info:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 8h.01M11 12h1v4h1"/><circle cx="12" cy="12" r="9"/></svg>'};+function toast(msg,type){+  type=type||'info';+  const box=document.getElementById('toasts'); if(!box||!msg)return;+  const el=document.createElement('div'); el.className='toast '+type;+  el.innerHTML=(TOAST_ICONS[type]||TOAST_ICONS.info)+'<span class="tmsg"></span>';+  el.querySelector('.tmsg').textContent=msg;+  while(box.children.length>=4) box.removeChild(box.firstChild); // cap visible+  box.appendChild(el);+  const kill=()=>{ el.classList.add('out'); setTimeout(()=>el.remove(),200); };+  el.onclick=kill;+  if(type!=='error') setTimeout(kill,4000); // errors stay until dismissed+}+// drain AI-pushed toasts (POST /ui/toast queues them server-side)+async function pollToasts(){+  try{ const q=await fetch('ui/toasts').then(r=>r.json());+    (q.toasts||[]).forEach(t=>toast(t.message,t.type)); }catch(e){}+  setTimeout(pollToasts,1500);+}+pollToasts();+// ── Tools menu: Project Manager launches each Node tool on demand and iframes it (served+// through Project Manager's own /tools/<slug>/ reverse proxy, so it stays on this origin). ──+const TOOL_META={ cfl:{title:'Chip Fetcher',sub:'wiki-first component sourcing'}, klm:{title:'Library Manager',sub:'KiCad libraries'} };+function toggleToolsMenu(open){ const m=document.getElementById('tools-menu'),b=document.getElementById('tools-btn'); if(!m)return; const on=open!==undefined?open:!m.classList.contains('open'); m.classList.toggle('open',on); b.classList.toggle('open',on); }+document.getElementById('tools-btn')?.addEventListener('click',(e)=>{ e.stopPropagation(); toggleToolsMenu(); });+document.addEventListener('click',(e)=>{ if(!e.target.closest('.tools-wrap')) toggleToolsMenu(false); });+document.querySelectorAll('#tools-menu .menu-item[data-tool]').forEach(el=>el.addEventListener('click',()=>{ toggleToolsMenu(false); openTool(el.getAttribute('data-tool')); }));+document.getElementById('sortby')?.addEventListener('change',(e)=>{ sortMode=e.target.value; if(!boardSlug&&!projectSlug)renderGallery(); });+// Home gallery toolbar: live search + sort. Sort default (most recent) is set on #sortMode.+document.getElementById('home-q')?.addEventListener('input',()=>{ if(!boardSlug&&!projectSlug)renderGallery(); });+document.getElementById('home-sort-sel')?.addEventListener('change',(e)=>{ sortMode=e.target.value; if(!boardSlug&&!projectSlug)renderGallery(); });+async function openTool(slug){+  const meta=TOOL_META[slug]||{title:slug,sub:''};+  document.getElementById('tv-title').textContent=meta.title;+  document.getElementById('tv-sub').textContent=meta.sub;+  const view=document.getElementById('tool-view'), frame=document.getElementById('tv-frame'), load=document.getElementById('tv-load');+  frame.src='about:blank'; load.style.display='grid'; view.classList.add('open');+  toast('Starting '+meta.title+'…','info');+  try{+    const r=await fetch('tools/launch/'+slug,{method:'POST'}).then(r=>r.json());+    if(!r.ok) throw new Error(r.error||'launch failed');+    frame.onload=()=>{ load.style.display='none'; };+    frame.src=r.base; // e.g. tools/klm/  (relative → served via Project Manager proxy)+    toast(meta.title+' ready','success');+  }catch(err){ load.innerHTML='<div style="color:var(--red)">Could not start '+meta.title+'.<br><span class="muted" style="font-size:12px">Ensure Node is installed and the tool package is present.</span></div>'; toast('Could not start '+meta.title,'error'); }+}+function closeTool(){ const view=document.getElementById('tool-view'), frame=document.getElementById('tv-frame'); view.classList.remove('open'); setTimeout(()=>{ frame.src='about:blank'; },200); }+document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && document.getElementById('tool-view').classList.contains('open')) closeTool(); });+// Cross-probe relay: the schematic + PCB view iframes are same-origin; when a part+// is clicked in one it posts {adomXprobe, ref} up here, and we forward it to the+// OTHER view so it highlights + recenters the same reference (KiCad-style probe).+window.addEventListener('message', (e)=>{+  const d=e.data; if(!d || !d.adomXprobe || (!d.ref && !d.clear)) return; // select OR deselect+  document.querySelectorAll('.bo-embed').forEach((f)=>{ if(f.contentWindow && f.contentWindow!==e.source){ try{ f.contentWindow.postMessage(d,'*'); }catch(err){} } });+}); let projectSlug=null, projectBoards=[]; // when a PROJECT is open: its board deps (project → boards → components) // the user's machine env (OS + home + default delivery folders), from /api/env let env={os:'linux',home:'',sep:'/',dest_folders:[]};@@ -310,9 +639,24 @@ let env={os:'linux',home:'',sep:'/',dest_folders:[]}; // components + their chosen target libraries for the open Add modal let userLibs={symLibs:[],fpLibs:[]}, curPlan=[]; function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; }-const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};+const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule')||t.includes('circuit');}; const isProject=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('project');-const isListable=c=>isBoard(c)||isProject(c);+// A tagged "<Name> Library" page (what a board depends on) — list it alongside boards+// so libraries are browsable directly, not only reachable through their board.+const isLibrary=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('library');+// A board-specific library is named "<board-slug>-library" and its parent board/+// molecule/project is itself in the list. Those aren't standalone: we hide them from+// the top level and surface them under their parent via a "Library" expander on the+// board row. A library with no matching parent (e.g. adom-machine-parts-library) is+// standalone and lists on its own.+function parentBoardOf(c){+  if(!isLibrary(c)||!c.slug.endsWith('-library'))return null;+  const p=c.slug.slice(0,-'-library'.length), b=bySlug[p];+  return (b&&(isBoard(b)||isProject(b)))?p:null;+}+function librariesFor(slug){ return all.filter(c=>parentBoardOf(c)===slug); }+const isStandaloneLibrary=c=>isLibrary(c)&&!parentBoardOf(c);+const isListable=c=>isBoard(c)||isProject(c)||isStandaloneLibrary(c); const qval=()=>document.getElementById('q').value.toLowerCase(); const rowsEl=()=>document.getElementById('rows'); function makeRow(slug,title,sub,onclick,tag,sel){@@ -323,19 +667,107 @@ function makeRow(slug,title,sub,onclick,tag,sel){   return d; } // keywords per component type, used to pre-select the closest of the USER's own-// libraries (discovered from their machine) — never hard-coded personal names.+// libraries (discovered from their machine), never hard-coded personal names. const TYPE_KEYWORDS={'Capacitors':['cap'],'Resistors':['res'],'MOSFETs':['fet','mos'],'Transistors':['fet','transistor','bjt'],'Inductors':['induct','coil','choke'],'LEDs':['led'],'Diodes':['diode'],'Connectors':['conn'],'Microcontrollers':['mcu','micro'],'Power ICs':['power','regulat','pmic'],'Crystals & Oscillators':['crystal','osc','xtal'],'ICs':['ic','misc'],'Other':['misc']}; const selected=new Set();-// best guess among the user's discovered libraries for a component type-function bestLib(libs,type){const kws=TYPE_KEYWORDS[type]||[];for(const kw of kws){const m=libs.find(l=>l.name.toLowerCase().includes(kw));if(m)return m.name;}return libs[0]?libs[0].name:'';}-function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c));}-function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c));}+// A user library name reduced to its identifying token: "Wago_BLT" -> "wago",+// "JLC_Resistors_BLT" -> "jlc resistors". Used for MANUFACTURER matching below.+function libNameToken(name){return (name||'').replace(/[ _-]*BLT$/i,'').replace(/[_-]+/g,' ').trim().toLowerCase();}+// Match one of the user's libraries by MANUFACTURER. Personal footprint libraries are+// commonly named per manufacturer (Wago_BLT, Bosch_BLT, Microchip_BLT, TI_BLT), which+// no category keyword can ever match — without this every component fell through to+// libs[0] and was silently filed under whichever library happened to be first.+// Tokens shorter than 3 chars are skipped (a bare "ti"/"gct" would match inside+// unrelated words); the longest matching token wins so the most specific library does.+function libByManufacturer(libs,c){+  if(!c)return '';+  const hay=((c.mfr||'')+' '+(c.manufacturer||'')+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();+  if(!hay.trim())return '';+  let best='';+  for(const l of libs){+    const tok=libNameToken(l.name);+    if(tok.length<3)continue;+    const re=new RegExp('(^|[^a-z0-9])'+tok.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([^a-z0-9]|$)');+    if(re.test(hay)&&tok.length>libNameToken(best).length)best=l.name;+  }+  return best;+}+// Best guess among the user's discovered libraries: manufacturer-named library first,+// then a category-named one. NO libs[0] fallback — an unmatched component returns ''+// so the UI asks the user to pick, instead of silently delivering into a wrong library.+// A category keyword must start a WORD in the library name. A bare substring test+// silently mis-filed parts: 'osc' matches "B(osc)h", 'ic' matches "M(ic)rochip", so+// crystals landed in Bosch_BLT and every IC in Microchip_BLT.+function libByCategory(libs,type){+  for(const kw of (TYPE_KEYWORDS[type]||[])){+    const re=new RegExp('(^|[^a-z0-9])'+kw.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'));+    const x=libs.find(l=>re.test(l.name.toLowerCase()));+    if(x)return x.name;+  }+  return '';+}+function bestLib(libs,type,c){+  if(!libs||!libs.length)return '';+  return libByManufacturer(libs,c)||libByCategory(libs,type)||'';+}+function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c),c);}+function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c),c);}+// Load the user's KiCad libraries, POLLING until we actually HAVE libraries. An empty+// result is never latched: the machine can be detected while the shell probe that reads+// the lib tables still fails (bridge warming up, shell permission/reason gate), and+// treating that as "you have no libraries" left every dropdown permanently dead with no+// recovery short of a reload. Poll ~2min, then surface a retry instead of failing silent.+let libsGaveUp=false;+const libCount=()=>((userLibs.symLibs||[]).length+(userLibs.fpLibs||[]).length);+function loadLibraries(attempt){+  attempt=attempt||0;+  fetch('api/libraries').then(r=>r.json()).then(l=>{+    const n=l?((l.symLibs||[]).length+(l.fpLibs||[]).length):0;+    if(n){ userLibs=l; libsGaveUp=false; redrawLibSelects(); return; }   // only latch a REAL answer+    if(attempt<48) setTimeout(()=>loadLibraries(attempt+1), 2500);+    else { libsGaveUp=true; redrawLibSelects(); }+  }).catch(()=>{ if(attempt<48) setTimeout(()=>loadLibraries(attempt+1), 3000); else {libsGaveUp=true;redrawLibSelects();} });+}+// Libraries can land AFTER a modal is already open; repaint its table so the user+// doesn't sit looking at empty dropdowns that the app has since been able to fill.+function redrawLibSelects(){+  const m=document.getElementById('modal');+  if(!m||m.style.display!=='flex'||!curPlan.length)return;+  if(typeof curRedraw==='function')curRedraw();+}+let curRedraw=null;+// Adom hover tooltip for ANY element carrying data-tip — reuses the #tip box (dark,+// teal-bordered), clamped to the viewport so it never runs off an edge.+(function(){+  function box(){ let t=document.getElementById('tip'); if(!t){t=document.createElement('div');t.id='tip';document.body.appendChild(t);} return t; }+  let cur=null;+  function place(x,y){ const t=box(),r=t.getBoundingClientRect(); let lx=x+14,ty=y+16;+    if(lx+r.width>innerWidth-8) lx=x-r.width-14; if(ty+r.height>innerHeight-8) ty=y-r.height-10;+    t.style.left=Math.max(8,Math.min(lx,innerWidth-r.width-8))+'px'; t.style.top=Math.max(8,Math.min(ty,innerHeight-r.height-8))+'px'; }+  document.addEventListener('mouseover',e=>{ const el=e.target.closest&&e.target.closest('[data-tip]'); if(!el)return; cur=el; const t=box(); t.textContent=el.getAttribute('data-tip'); t.style.whiteSpace='normal'; t.style.maxWidth='240px'; t.style.display='block'; place(e.clientX,e.clientY); });+  document.addEventListener('mousemove',e=>{ if(cur){ if(cur.isConnected&&cur.matches(':hover')) place(e.clientX,e.clientY); else { cur=null; box().style.display='none'; } } });+  document.addEventListener('mouseout',e=>{ const el=e.target.closest&&e.target.closest('[data-tip]'); if(el&&el===cur){ cur=null; box().style.display='none'; } });+})(); function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();} function updateToolbar(){   const n=selected.size;   const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';-  const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}+  // Both toolbar buttons grey out the SAME way (.off) with an Adom hover tooltip+  // (data-tip → the #tip box), never the native `disabled` (which swallows hover so+  // the tooltip can't show). Clicks are guarded in openAddLib / openAddProj.+  const be = boardSlug ? blockedEda(boardSlug) : null;   // 'Altium' | 'Fusion' | null+  const b=document.getElementById('btn-addlib');+  if(b){ b.textContent=n?`Add ${n} to Library`:'Add to Library';+    if(be) setOff(b, true, `Library delivery for ${be} components is coming soon`);+    else setOff(b, n===0, 'Select components below to add them to your library'); }   const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s));+  const bp=document.getElementById('btn-addproj');+  if(bp){ if(be) setOff(bp, true, `Project delivery for ${be} boards is coming soon`); else setOff(bp, false, ''); }+}+// Mark a button unavailable: greyed (.off), still hoverable, with an Adom tooltip.+function setOff(el, off, tip){+  el.classList.toggle('off', off);+  if(off) el.setAttribute('data-tip', tip); else el.removeAttribute('data-tip'); } // 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%.@@ -358,17 +790,186 @@ async function loadList(fresh){     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(()=>{});+    loadLibraries();     render();     if(!fresh) finishLoader();+    if(fresh) toast('Refreshed, '+all.length+' project'+(all.length===1?'':'s'),'success');   }catch(e){     if(!fresh) finishLoader();-    rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';+    rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load, retry</div>';+    toast('Could not load projects from the wiki','error');   }finally{ if(btn)btn.classList.remove('spin'); } }-function render(){ if(boardSlug)renderDeps(); else if(projectSlug)renderProjectBoards(); else renderBoards(); }+function render(){+  const ws=document.getElementById('workspace');+  const sr=document.getElementById('sortrow'); if(sr)sr.style.display='none';+  if(boardSlug){ ws.classList.remove('home'); renderDeps(); }+  else if(projectSlug){ ws.classList.remove('home'); renderProjectBoards(); }+  else renderGallery();   // top level = the full-screen gallery (adds .home)+}+// ── HOME gallery: the full-screen board wall. Kind-colored hero cards; hover swaps+// its hero gently zooms on hover. Personal is flat; Organization + Public group by+// org with avatar section headers. Default sort = most recent (last push). ──+const KIND_COLOR={board:'#79b8ff',molecule:'#f0a81c',circuit:'#f472b6',library:'#b9a4ff',project:'#2dd4bf'};+function galleryTabs(){+  // Tab counts match the visible set (same filters the wall applies, incl. library hiding).+  const boards=all.filter(isListable).filter(matchesFilter);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me).length;+  const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)).length;+  const defs=[['mine','Personal',mineAll],['org','Organization',orgAll],['public','Public',null]];+  const bar=document.getElementById('home-tabs');+  bar.innerHTML=defs.map(([k,label,n])=>`<button class="segbtn${boardTab===k?' active':''}" data-tab="${k}">${label}${n!=null?`<span class="segn">${n}</span>`:''}</button>`).join('');+  bar.querySelectorAll('.segbtn').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; userView=null; orgView=null; renderGallery(); });+}+// Org section headers can collapse (a chevron hides the whole group) and, on the Public+// page, carry the org's aggregate engagement so the "top organizations" ranking is explicit.+let collapsedGroups=new Set();+function secHead(label,count,org,opts){+  opts=opts||{};+  const h=document.createElement('div');h.className='g-sec-head';+  const collKey=opts.collKey;+  const collapsed=collKey&&collapsedGroups.has(collKey);+  // A collapsible group gets a chevron (toggles the group). If opts.orgSlug is set, the+  // avatar+name are their own click target that drills into that org's boards — so+  // collapsing and drilling in are two distinct clicks on the same header.+  const chev=collKey?`<button class="gsh-chev${collapsed?' coll':''}" title="${collapsed?'expand':'collapse'} group"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>`:'';+  const av=org?`<img class="gsh-avatar" src="org-avatar/${encodeURIComponent(org)}" onerror="this.remove()" alt="">`:'';+  const eng=(opts.eng!=null)?`<span class="gsh-eng" title="total engagement across this org's public boards">▲ ${Math.round(opts.eng)}</span>`:'';+  const drill=opts.orgSlug;+  h.innerHTML=`${chev}<span class="gsh-main${drill?' link':''}"${drill?` title="view all of ${esc(drill)}'s boards"`:''}>${av}<span class="gsh-label">${esc(label)}</span><span class="gsh-count">${count}</span>${eng}</span>`;+  if(collKey){ h.querySelector('.gsh-chev').onclick=(e)=>{ e.stopPropagation(); collapsedGroups.has(collKey)?collapsedGroups.delete(collKey):collapsedGroups.add(collKey); renderGallery(); }; }+  if(drill){ h.querySelector('.gsh-main').onclick=()=>viewOrg(drill); }+  return h;+}+function galleryCard(c){+  const kind=boardKind(c), kc=KIND_COLOR[kind]||'#79b8ff';+  const card=document.createElement('div'); card.className='gcard'; card.dataset.slug=c.slug;+  card.style.setProperty('--kc',kc);+  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>`:'';+  const edaSpan=Array.isArray(c.eda)?edaTags(c.eda):'';+  const star=c.starred?'<span class="g-star" title="starred on the wiki">★</span>':'';+  const mono=esc(((c.title||c.slug).trim().charAt(0)||'?'));+  const t=c.updated?Date.parse(c.updated):NaN;+  const when=isNaN(t)?'':timeago(Math.floor(t/1000));+  card.innerHTML=`+    <div class="g-thumb">+      <div class="g-ph">${mono}</div>+      <img class="g-hero" loading="lazy" alt="">+      ${star}+    </div>+    <div class="g-body">+      <div class="g-title">${esc(c.title||c.slug)}</div>+      <div class="g-meta"><span class="tag kind ${kind}">${kind}</span>${edaSpan}${visTag}</div>+      <div class="g-sub"><span class="g-owner${c.owner?' link':''}" title="${c.owner?'view '+esc(c.owner)+"'s boards":''}">${esc(c.owner||c.slug)}</span><span class="g-right"><span class="g-eng" title="engagement score">▲ ${Math.round(c.score||0)}</span>${when?`<span class="g-when">${when}</span>`:''}</span></div>+    </div>`;+  // hero image (lazy). On 404/no-hero, hide it so the kind-colored placeholder shows.+  const hero=card.querySelector('.g-hero');+  hero.onerror=()=>{ hero.style.display='none'; };   // 404/no hero → kind-colored monogram shows through+  hero.src='board-hero/'+c.slug;+  card.onclick=()=>isProject(c)?openProject(c.slug):openBoard(c.slug);+  // clicking the author name pivots the wall to that person's boards (not the card open)+  if(c.owner){ const ow=card.querySelector('.g-owner'); ow.onclick=(e)=>{ e.stopPropagation(); viewUser(c.owner); }; }+  return card;+}+function galleryEmpty(tab){+  if(tab==='mine')return '<div class="g-empty">No personal projects yet.<br>Push a board tagged <b>board</b>/<b>molecule</b> (without <span style="font-family:monospace">--org</span>), then press ↻.</div>';+  if(tab==='org')return '<div class="g-empty">No projects in your organization(s) yet.</div>';+  return '<div class="g-empty">No public projects match.</div>';+}+// Clicking an author name (or an org header) filters the wall to that person's / org's+// boards, across every scope Project Manager can see. null = normal tab view.+let userView=null, orgView=null;+function viewUser(name){ if(!name)return; userView=name; orgView=null; renderGallery(); }+function clearUserView(){ userView=null; renderGallery(); }+function viewOrg(org){ if(!org)return; orgView=org; userView=null; renderGallery(); }+function clearOrgView(){ orgView=null; renderGallery(); }+const byScoreCmp=(a,b)=>((b.score||0)-(a.score||0))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase());+function renderGallery(){+  document.getElementById('workspace').classList.add('home');+  galleryTabs();+  fillChips(document.getElementById('home-chips'));+  const grid=document.getElementById('home-grid'); grid.innerHTML='';+  const searchEl=document.getElementById('home-q');+  searchEl.placeholder='search projects…';+  const q=(searchEl.value||'').trim().toLowerCase();+  const search=c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)||(c.owner||'').toLowerCase().includes(q);+  // Libraries are hidden by default; the Library filter chip reveals them (and pulls in+  // the board-embedded libraries too).+  const showLibs=filterTags.has('library');+  const pool=showLibs ? all.filter(c=>isListable(c)||isLibrary(c))+                      : all.filter(c=>isListable(c)&&boardKind(c)!=='library');+  // ── org drill-down: every board in one organization, newest first ──+  if(orgView){+    const list=sortBoards(pool.filter(c=>c.org===orgView).filter(search).filter(matchesFilter));+    grid.appendChild(orgBanner(orgView, list.length));+    if(!list.length){ const e=document.createElement('div'); e.className='g-empty'; e.textContent=`No boards in ${orgView}.`; grid.appendChild(e); return; }+    list.forEach(c=>grid.appendChild(galleryCard(c)));+    return;+  }+  // ── author drill-down: every board by one person, newest first ──+  if(userView){+    const list=sortBoards(pool.filter(c=>c.owner===userView).filter(search).filter(matchesFilter));+    grid.appendChild(userBanner(userView, list.length));+    if(!list.length){ const e=document.createElement('div'); e.className='g-empty'; e.textContent=`No boards by ${userView}.`; grid.appendChild(e); return; }+    list.forEach(c=>grid.appendChild(galleryCard(c)));+    return;+  }+  if(boardTab==='mine'){+    const list=sortBoards(pool.filter(c=>c.owner&&c.owner===me).filter(search).filter(matchesFilter));+    if(!list.length){ grid.innerHTML=galleryEmpty('mine'); return; }+    list.forEach(c=>grid.appendChild(galleryCard(c)));+  } else if(boardTab==='org'){+    const list=pool.filter(c=>c.org&&orgs.includes(c.org)).filter(search).filter(matchesFilter);+    if(!list.length){ grid.innerHTML=galleryEmpty('org'); return; }+    orgs.forEach(o=>{ const inOrg=sortBoards(list.filter(c=>c.org===o)); if(!inOrg.length)return;+      const key='org:'+o;+      grid.appendChild(secHead(o,inOrg.length,o,{collKey:key,orgSlug:o}));+      if(!collapsedGroups.has(key)) inOrg.forEach(c=>grid.appendChild(galleryCard(c))); });+  } else {+    // PUBLIC. Searching/filtering → flat results. Otherwise a leaderboard: the top 9+    // boards overall, then a deliberate TOP ORGANIZATIONS ranking — the strongest orgs+    // by aggregate engagement, each a collapsible group showing its top 3 boards.+    const pub=pool.filter(c=>{const v=(c.visibility||'').toLowerCase();return v==='public'||v==='open';}).filter(matchesFilter);+    if(q){+      const list=sortBoards(pub.filter(search));+      if(!list.length){ grid.innerHTML=galleryEmpty('public'); return; }+      grid.appendChild(secHead('Results',list.length,null)); list.forEach(c=>grid.appendChild(galleryCard(c)));+      return;+    }+    if(!pub.length){ grid.innerHTML=galleryEmpty('public'); return; }+    const top9=pub.slice().sort(byScoreCmp).slice(0,9);+    grid.appendChild(secHead('Trending',top9.length,null));+    top9.forEach(c=>grid.appendChild(galleryCard(c)));+    const groups={}; pub.forEach(c=>{ if(c.org){ (groups[c.org]=groups[c.org]||[]).push(c); } });+    const engOf=o=>groups[o].reduce((m,c)=>m+(c.score||0),0);+    const orgRank=Object.keys(groups).sort((a,b)=>engOf(b)-engOf(a)||a.localeCompare(b)).slice(0,6);+    if(orgRank.length){+      grid.appendChild(secHead('Top organizations',orgRank.length,null));+      orgRank.forEach(o=>{ const arr=groups[o].slice().sort(byScoreCmp); const key='puborg:'+o;+        grid.appendChild(secHead(o,arr.length,o,{collKey:key,eng:engOf(o),orgSlug:o}));+        if(!collapsedGroups.has(key)) arr.slice(0,3).forEach(c=>grid.appendChild(galleryCard(c))); });+    }+  }+}+// Full-width banner for the author drill-down, with a ✕ to return to the tabs.+function userBanner(name,count){+  const h=document.createElement('div'); h.className='g-sec-head g-userbanner';+  h.innerHTML=`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg><span>Boards by ${esc(name)}</span><span class="gsh-count">${count}</span><button class="g-clearuser" title="back to all">✕</button>`;+  h.querySelector('.g-clearuser').onclick=(e)=>{ e.stopPropagation(); clearUserView(); };+  return h;+}+// Full-width banner for the org drill-down (org avatar + name), with a ✕ to return.+function orgBanner(org,count){+  const h=document.createElement('div'); h.className='g-sec-head g-userbanner';+  h.innerHTML=`<img class="gsh-avatar" src="org-avatar/${encodeURIComponent(org)}" onerror="this.remove()" alt="">`++    `<span>Boards in ${esc(org)}</span><span class="gsh-count">${count}</span><button class="g-clearuser" title="back to all">✕</button>`;+  h.querySelector('.g-clearuser').onclick=(e)=>{ e.stopPropagation(); clearOrgView(); };+  return h;+} // The Personal/Organization/Public nav lives in a persistent bar (not inside the-// scrolling list), so it's always available — even while a board is open. Clicking+// scrolling list), so it's always available, even while a board is open. Clicking // a tab leaves the current project and returns you to that board list. function renderTabs(){   const boards=all.filter(isListable);@@ -381,13 +982,17 @@ function renderTabs(){ } // 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=[['project','Project'],['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];+// Chips are an INCLUDE set shown all-on EXCEPT Library, so it reads as "everything but+// libraries is in the list". Clicking Library adds libraries; clicking another off hides+// that category.+let filterTags=new Set(['project','molecule','board','circuit','kicad','altium','fusion']);+const FILTER_CHIPS=[['project','Project'],['molecule','Molecule'],['board','Board'],['circuit','Circuit'],['library','Library'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']]; // Render the tag-filter chips into any container (shared by the top bar and the public search). function fillChips(el){   if(!el)return;   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(); });+  el.querySelectorAll('.fchip').forEach(ch=>ch.onclick=()=>{ const t=ch.dataset.t; filterTags.has(t)?filterTags.delete(t):filterTags.add(t);+    document.getElementById('workspace').classList.contains('home')?renderGallery():renderBoards(); }); } function renderFilterBar(){   const el=document.getElementById('tagfilter'); if(!el)return;@@ -397,9 +1002,16 @@ function renderFilterBar(){ } function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); } function isFusionBoard(slug){ const e=boardEdaOf(bySlug[slug]||{slug}); return !!(e&&e.includes('fusion')&&!e.includes('kicad')); }+// Add Project delivers a KiCad project; Altium/Fusion boards can't be delivered yet.+// Block those (unless the board also carries KiCad, which always works).+// Which EDA blocks delivery for this board — 'Altium' | 'Fusion' | null. KiCad always+// works; a non-KiCad board's EDA (Altium/Fusion) isn't deliverable yet. Used to gray+// out BOTH Add Project and Add to Library with an EDA-specific "coming soon" tooltip.+function blockedEda(slug){ const e=boardEdaOf(bySlug[slug]||{slug})||[]; if(e.includes('kicad'))return null; if(e.includes('altium'))return 'Altium'; if(e.includes('fusion'))return 'Fusion'; return null; }+function addProjBlocked(slug){ return !!blockedEda(slug); } // 3D orientation is keyed on the GLB's own up-axis stamp (asset.extras.adomMolecule.upAxis), // NOT the EDA. step2glb --molecule normalizes to Z-up and stamps "z"; glTF/model-viewer is-// Y-up, so a Z-up GLB needs a -90° X tip. Raw (un-pipelined) exports carry no stamp — they're+// Y-up, so a Z-up GLB needs a -90° X tip. Raw (un-pipelined) exports carry no stamp, they're // already Y-up, so no rotation. This is per-board and correct whether a board went through the // pipeline or not. The stamp is fetched once per board and cached. const FUSION_ORIENT='0deg -90deg 0deg';@@ -413,25 +1025,35 @@ async function fetchUpAxis(slug,ver){ } function mvOrientForAxis(up){ return up==='z' ? ` orientation="${FUSION_ORIENT}"` : ''; } function matchesFilter(c){-  if(!filterTags.size) return true;-  const kindSel=['project','molecule','board'].filter(t=>filterTags.has(t));+  const kind=boardKind(c);+  const kindSel=['project','molecule','board','circuit','library'].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; }+  // Kind facet is an include set. If any kind chips are on, the board's kind must be one+  // of them; if none are on, fall back to "everything except library".+  if(kindSel.length){ if(!kindSel.includes(kind)) return false; }+  else if(kind==='library') return false;+  // EDA facet only constrains boards that actually declare an EDA — projects and+  // eda-less boards always pass, so the default all-on EDA chips never hide them.+  if(edaSel.length){ const eda=boardEdaOf(c); if(eda&&eda.length&&!eda.some(t=>edaSel.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>';+  document.getElementById('crumb').innerHTML='';   // no redundant "Projects" heading above the tabs   const q=qval(),el=rowsEl();el.innerHTML='';   renderTabs(); renderFilterBar();   const boards=all.filter(isListable);-  const mineAll=boards.filter(c=>c.owner&&c.owner===me);-  const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org));+  // When the Library filter is on, pull the board-embedded libraries back into the+  // rendered list so they show up as normal rows (they're hidden under their board+  // otherwise). Tab counts still come from the base `boards` set.+  const pool=filterTags.has('library') ? all.filter(c=>isListable(c)||isLibrary(c)) : boards;   const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';-  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;}+  // Sort dropdown: only on the personal/org lists (Public is its own leaderboard).+  const srow=document.getElementById('sortrow'); if(srow)srow.style.display=boardTab==='public'?'none':'';+  if(boardTab==='public'){renderPublic(pool.filter(matchesFilter),el);return;}+  const mineList=pool.filter(c=>c.owner&&c.owner===me), orgList=pool.filter(c=>c.org&&orgs.includes(c.org));+  const list=(boardTab==='mine'?mineList:orgList).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 {@@ -456,9 +1078,9 @@ function renderBoards(){ // A board row. Boards you've starred on the wiki show a ★ and pin to the top of // their list (starred status comes from the wiki API; there's no toggle here). const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};-function boardKind(c){const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('project')?'project':t.includes('molecule')?'molecule':'board';}+function boardKind(c){const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('project')?'project':t.includes('molecule')?'molecule':t.includes('circuit')?'circuit':t.includes('board')?'board':t.includes('library')?'library':'board';} let edaCache={};-// a board can carry more than one toolchain (e.g. KiCad + Fusion) — render one tag each.+// 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=list=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.outerHTML=edaTags(list);});@@ -489,7 +1111,7 @@ function openFolderBrowser(start,onPick){   async function load(p){     listEl.innerHTML='<div class="fb-loading">loading…</div>';     // If the folder listing hasn't come back in 5s, the desktop bridge probably can't-    // run shell commands — tell the user what to check instead of spinning forever.+    // run shell commands, tell the user what to check instead of spinning forever.     const warn=setTimeout(()=>{ if(listEl.querySelector('.fb-loading')) listEl.innerHTML='<div class="fb-loading">Still loading…<br><span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span></div>'; },5000);     let d;     try{@@ -503,7 +1125,7 @@ function openFolderBrowser(start,onPick){     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>`; });+    (d.dirs||[]).forEach(x=>{ html+=`<div class="fb-row"data-p="${esc(x.path)}"><svg class="fb-ico" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4l2 2.5h7A1.5 1.5 0 0 1 19 9v8.5A1.5 1.5 0 0 1 17.5 19h-13A1.5 1.5 0 0 1 3 17.5z"/></svg> ${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));@@ -523,11 +1145,50 @@ function boardRow(c){   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=()=>isProject(c)?openProject(c.slug):openBoard(c.slug);+  // If this board/molecule/project has its own (non-standalone) library, add a+  // "Library" expander that reveals it inline instead of listing it separately.+  const libs=librariesFor(c.slug);+  if(libs.length){+    const btn=document.createElement('button');btn.type='button';btn.className='libtoggle';+    btn.innerHTML=`<span class="cv">▾</span>${libs.length>1?`${libs.length} libraries`:'Libraries'}`;+    btn.title=`show the library for this ${kind}`;+    btn.onclick=(e)=>{ e.stopPropagation(); toggleLibChild(d,btn,libs); };+    d.appendChild(btn);   // its own line, below the tag row+  }   if(!edaKnown)loadEda(c.slug);   return d; }+function toggleLibChild(row,btn,libs){+  const nx=row.nextElementSibling;+  if(nx&&nx.classList.contains('libchild')){ nx.remove(); btn.classList.remove('open'); return; }+  const wrap=document.createElement('div');wrap.className='libchild';+  libs.forEach(l=>{+    const lr=document.createElement('div');lr.className='row libchild-row';lr.dataset.slug=l.slug;+    const vis=(l.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>`:'';+    const edaSpan=Array.isArray(l.eda)?edaTags(l.eda):'';+    const other=edaSpan+visTag;+    // embedded library row mirrors a normal board row: purple "library" kind tag FIRST,+    // then its eda/visibility tags, all on one line.+    lr.innerHTML=`<div class="mpn">${l.title||l.slug}</div><div class="sub">${l.slug}${l.owner?`<span class="auth">${l.owner}</span>`:''}</div><div class="row-tags"><span class="tag kind library">library</span>${other}</div>`;+    lr.onclick=(ev)=>{ ev.stopPropagation(); openBoard(l.slug); };+    wrap.appendChild(lr);+  });+  row.after(wrap); btn.classList.add('open');+}+// Sort mode chosen in the list's "Sort" dropdown. Starred boards always pin to the+// top first; the mode orders everything else. alpha=A→Z, tag=molecule/library/project/+// board, eda=kicad/altium/fusion, score=engagement (desc). Ties fall back to alpha.+let sortMode='recent'; function sortBoards(list){-  return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase()));+  const alpha=(a,b)=>(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase());+  let cmp=alpha;+  if(sortMode==='recent'){ const ts=c=>{const t=Date.parse(c.updated||'');return isNaN(t)?0:t;}; cmp=(a,b)=>(ts(b)-ts(a))||alpha(a,b); }+  else if(sortMode==='tag'){ const rk={molecule:0,library:1,project:2,board:3}; cmp=(a,b)=>((rk[boardKind(a)]??9)-(rk[boardKind(b)]??9))||alpha(a,b); }+  else if(sortMode==='eda'){ const er={kicad:0,altium:1,fusion:2}; const rank=c=>{const e=boardEdaOf(c)||[];let m=9;e.forEach(x=>{if(er[x]!=null)m=Math.min(m,er[x]);});return m;}; cmp=(a,b)=>(rank(a)-rank(b))||alpha(a,b); }+  else if(sortMode==='score'){ cmp=(a,b)=>((b.score||0)-(a.score||0))||alpha(a,b); }+  return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||cmp(a,b)); } // The Public tab. DEFAULT (no search) = a popularity leaderboard: top 10 most // popular public projects, then top 10 most popular orgs. Typing a query clears@@ -577,24 +1238,26 @@ function renderPubResults(){     const h2=document.createElement('div');h2.className='cathead';h2.textContent='Popular Organizations';pr.appendChild(h2);     topOrgs.forEach(([org,a],i)=>{       const d=document.createElement('div');d.className='row orgrow';-      d.innerHTML=`<img class="orgicon-img" src="org-avatar/${encodeURIComponent(org)}" alt="" onerror="orgIconErr(this)"><span class="orgname">${org}</span><span class="orgmeta">· ${a.count} project${a.count>1?'s':''} ·</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span>`;+      d.innerHTML=`<img class="orgicon-img" src="org-avatar/${encodeURIComponent(org)}" alt="" onerror="orgIconErr(this)"><div class="orgtext"><div class="orgtop"><span class="orgname">${org}</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span></div><div class="orgmeta">${a.count} project${a.count>1?'s':''}</div></div>`;       d.onclick=()=>{pubOrg=org;renderPubResults();};       pr.appendChild(d);     });   } } async function openBoard(slug){+  document.getElementById('workspace').classList.remove('home');   // leave the gallery, interior takes over   boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;   const title=(bySlug[slug]||{}).title||slug;+  setSubject(title, slug);   document.getElementById('crumb').innerHTML=projectSlug     ? `<a href="#" onclick="backToTop();return false">Projects</a> / <a href="#" onclick="openProject('${projectSlug}');return false">${(bySlug[projectSlug]||{}).title||projectSlug}</a> / <span class="cactive">${title}</span>`     : `<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;   renderBoardOverview(slug);   rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';   try{ const r=await fetch('api/board/'+slug).then(r=>r.json());-    if(boardSlug!==slug)return; // navigated back/away while loading — don't paint stale parts-    curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }-  catch(e){ if(boardSlug!==slug)return; curDeps=[]; }+    if(boardSlug!==slug)return; // navigated back/away while loading, don't paint stale parts+    curDeps=(r.deps||[]).map(d=>d.slug); curBom=(r.bom||[]); fillBoardInfo(slug,r.log); }+  catch(e){ if(boardSlug!==slug)return; curDeps=[]; curBom=[]; }   if(boardSlug!==slug)return;   renderDeps(); }@@ -604,11 +1267,14 @@ 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 _wowner=c.org||c.owner||'';+  const _wikiUrl=_wowner?`https://wiki.adom.inc/${_wowner}/${encodeURIComponent(slug)}`:'';+  const _wikiBtn=_wikiUrl?`<a class="wikibtn" href="${_wikiUrl}" target="_blank" rel="noopener" title="open this page on the Adom wiki">Open on wiki ↗</a>`:'';   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>${_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-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>${_wikiBtn}</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">${viewerLoaderHTML('loading…')}</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">${viewerLoaderHTML('rendering…')}</div></div></div>@@ -639,7 +1305,7 @@ function fillBoardInfo(slug,log){   const lg=document.getElementById('bo-log');   if(!lg)return;   lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';-  // enrich each entry with what changed (components +/-, PCB/schematic) — lazily,+  // enrich each entry with what changed (components +/-, PCB/schematic), lazily,   // since it re-fetches each commit's PCB/schematic to diff them   fetch('api/board-activity/'+slug).then(r=>r.json()).then(d=>{     if(boardSlug!==slug)return;@@ -647,7 +1313,7 @@ function fillBoardInfo(slug,log){   }).catch(()=>{}); } // Red error bar at the top only when the page is actually missing the assets it-// needs to display (checked first via has_assets) — a board that still has its+// needs to display (checked first via has_assets), a board that still has its // KiCad source / 3D model renders fine (live) and gets NO bar even without render/. function showRenderWarning(slug,m){   if(boardSlug!==slug)return;@@ -660,10 +1326,10 @@ function showRenderWarning(slug,m){     bar=document.createElement('div'); bar.className='render-warn';     ov.insertBefore(bar,ov.firstChild);   }-  bar.innerHTML=`⚠ This ${kind} is missing the assets it needs to render (its KiCad board/schematic or 3D model aren't on the page), so its 3D, PCB and schematic won't load. Reupload the ${kind} with the <b>publish-board</b> skill.`;+  bar.innerHTML=`⚠ This ${kind} is missing the assets it needs to render (its KiCad board/schematic or 3D model aren't on the page), so its 3D, PCB and schematic won't load. Reupload the ${kind} with the <b>molecule-publish</b> skill.`; } async function loadBoardMedia(slug){-  // service-kicad renders in the background (a board GLB can take ~a minute) —+  // service-kicad renders in the background (a board GLB can take ~a minute) -   // poll until it's done, keeping the "rendering…" placeholders meanwhile.   let m={rendering:true};   for(let i=0;i<60 && m.rendering;i++){@@ -685,20 +1351,20 @@ let boardVersion=null, curBoardMedia=null; function verQ(){   const m=curBoardMedia||{};   if(!(boardVersion && m.current && boardVersion!==m.current)) return '';-  // Append the version's content hash so the URL is content-addressed — the browser (and+  // Append the version's content hash so the URL is content-addressed, the browser (and   // model-viewer) can't serve a stale cache entry for this ?v from a different render.   const e=(m.versions||[]).find(x=>x.version===boardVersion);   const h=(e&&e.hash)?'&h='+encodeURIComponent(e.hash):'';   return '?v='+encodeURIComponent(boardVersion)+h; } // 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.+// version pill changes, each asset fetch carries ?v=<version> for a past version. async function mountBoardAssets(slug){   const m=curBoardMedia||{}, q=verQ();   // Fusion/EAGLE renders carry their own colors baked in: the PCB already has the dark   // editor canvas + pre-dimmed copper pour (so skip dimFills, and match the pane bg to   // the #282828 canvas the paper-rect strip removes), and the schematic is on white-  // (EAGLE) so the pane goes white — makes it read like it's inside Fusion.+  // (EAGLE) so the pane goes white, makes it read like it's inside Fusion.   const fusion=isFusionBoard(slug);   const schOpt=fusion?{light:true,bg:'#ffffff'}:{light:true};   const pcbOpt=fusion?{bg:'#282828'}:{dimFills:0.4};@@ -711,64 +1377,45 @@ async function mountBoardAssets(slug){   if(pcb && m.has_pcb) showViewerLoader(pcb);   if(d3){     if(m.has_3d){-      const up=await fetchUpAxis(slug, boardVersion);   // GLB up-axis stamp → per-board orientation       if(boardSlug!==slug)return;       d3.style.position='relative'; d3.style.background='#0d0f14';-      d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`+-        `<div class="vload ov"><img src="loader.gif" alt=""></div>`;-      const mv=d3.querySelector('model-viewer'), ov=d3.querySelector('.vload.ov'), pct=ov.querySelector('.vload-pct');-      const done=()=>{ if(ov)ov.remove(); };-      // model-viewer streams the GLB and reports detail.totalProgress 0→1.-      mv.addEventListener('progress',e=>{ const p=e.detail&&e.detail.totalProgress; if(p!=null){ setViewerPct(pct,p); if(p>=1)done(); } });-      mv.addEventListener('load',done);+      // The canonical Babylon adom-3d-viewer, hosted BY Project Manager at same-origin /viewer3d:+      // it loads the wiki's own viewer bundle through Project Manager's /v3d/ proxy and the model+      // through Project Manager's authenticated board-glb route. Because both are same-origin, no+      // cross-site session cookie is needed — so PUBLIC and PRIVATE boards both render the+      // real Babylon viewer (a direct cross-origin wiki iframe 404s on a private page when+      // the SameSite=Lax cookie is stripped).+      const vq=boardVersion?('&v='+encodeURIComponent(boardVersion)):'';+      d3.innerHTML=`<iframe class="bo-embed" style="background:#0d0f14;border:0" src="viewer3d?slug=${encodeURIComponent(slug)}${vq}" allow="fullscreen" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`;     } else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;   }   const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');-  // Interactive viewers available for the version being shown. On a PAST version this-  // is that version's own archived list, so a board revision without archived viewers-  // falls back to its own static SVG instead of showing a newer revision.-  const vlist=viewersFor(m);-  const schViewer=(i)=>{ const n='sch-'+String(sheets[i]||'').toLowerCase(); return vlist.includes(n)?n:null; };+  if(sel)sel.style.display='none'; // the interactive schematic viewer shows the whole sheet+  // Interactive views: adom-schematic (schematic) + adom-2dboard (PCB),+  // served as self-contained embeds by the backend /board-view route and iframed here.   if(sch){-    if(sheets.length){-      const showSheet=async(i)=>{-        if(boardSlug!==slug)return;-        const vn=schViewer(i);-        if(vn) return mountViewer(sch, `board-viewer/${slug}/${vn}${verQ()}`);-        const p=showViewerLoader(sch);-        mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${i}${verQ()}`,p), schOpt);-      };-      if(sel){-        sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join('');-        sel.style.display=sheets.length>1?'':'none';-        sel.onchange=()=>showSheet(sel.value);-      }-      await showSheet(0);-    } else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }+    if(sheets.length){ sch.innerHTML=embedFrame(`board-view/${slug}/sch${q}`,'#ffffff'); }+    else { sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }   }   if(boardSlug!==slug)return;-  if(pcb){-    if(vlist.includes('layout')) mountViewer(pcb, `board-viewer/${slug}/layout${q}`);-    else if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), pcbOpt); }-    else pcb.innerHTML='<div class="bo-na">PCB view not available</div>';-  }+  if(pcb){ if(m.has_pcb){ pcb.innerHTML=embedFrame(`board-view/${slug}/pcb${q}`,'#0d1117'); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; } }-// Which interactive viewers apply to the version currently selected.-function viewersFor(m){-  if(!boardVersion) return m.viewers||[];-  const ent=(m.versions||[]).find(v=>v.version===boardVersion);-  return (ent&&ent.viewers)||[];-}-// Mount a self-contained interactive viewer (iframe). It ships its own chrome-// (layers/nets panel, hover cards), so no mountSvg post-processing applies.-function mountViewer(el, src){-  el.style.position='relative'; el.style.background='#0d1117';-  el.innerHTML=`<iframe class="bo-viewer" src="${src}" title="interactive board view"></iframe>`+-    `<div class="vload ov"><img src="loader.gif" alt=""></div>`;-  const fr=el.querySelector('iframe'), ov=el.querySelector('.vload.ov');-  const done=()=>{ if(ov&&ov.parentNode) ov.remove(); };-  fr.addEventListener('load',done);-  setTimeout(done,15000); // never strand the spinner if load never fires+// Build an interactive-viewer iframe with a loading spinner that clears on load.+function embedFrame(src,bg){ return `<iframe class="bo-embed" style="background:${bg}" src="${src}" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`; }+// The wiki's own 3D viewer (adom-3d-viewer-babylon9), iframed so the model looks+// exactly as it does on the wiki page. The viewer page is self-contained (bundle ++// Draco + CSP) and has no frame-ancestors restriction, so it embeds cross-origin.+function wikiViewer3d(slug){+  // Owner-QUALIFIED viewer route. The bare /viewer/3d/component/<slug> form is legacy and+  // resolves owner:null, which 404s for private org pages (adom/adom-3d-viewer #1, Colby):+  // the viewer then can't derive the model and the pane errors out. Always pass the page's+  // own owner (org for org pages, else the author), and NEVER an ownerless lookup. Pass the+  // slug verbatim — never append "-board" (many board slugs already end in it).+  const c=bySlug[slug]||{};+  const owner=c.org||c.owner;+  if(!owner){ return '<div class="bo-na">3D not available</div>'; }  // no owner ⇒ don't send a lookup that can't resolve+  const src=`https://wiki.adom.inc/viewer/3d/${encodeURIComponent(owner)}/component/${encodeURIComponent(slug)}`;+  return `<iframe class="bo-embed" style="background:#0d0f14;border:0" src="${src}" allow="fullscreen" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`; } function selectVersion(v){   const m=curBoardMedia||{};@@ -791,7 +1438,7 @@ 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>`;+  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>`; } // Loading UI shared by the 3D / schematic / PCB viewers: the Adom mark + a percent below. function setViewerPct(el,frac){ if(el) el.textContent=Math.round(Math.max(0,Math.min(1,frac))*100)+'%'; }@@ -860,7 +1507,7 @@ function mountSvg(box,svgText,opt){     // 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.+    // 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@@ -906,18 +1553,26 @@ const TYPE_RULES=[   [/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'], ]; const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];+const CAT_TAGS={capacitors:'Capacitors',resistors:'Resistors',inductors:'Inductors','power-ics':'Power ICs',diodes:'Diodes',leds:'LEDs',connectors:'Connectors',mosfets:'MOSFETs',transistors:'Transistors',microcontrollers:'Microcontrollers',crystals:'Crystals & Oscillators',ics:'ICs'}; function typeOf(c){+  // an explicit category tag wins over keyword-guessing (a resistor whose description+  // mentions the circuit it's used in must still group under Resistors).+  for(const t of (c.tags||[]).map(x=>(''+x).toLowerCase())) if(CAT_TAGS[t]) return CAT_TAGS[t];   const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();   for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;   return 'Other'; } function renderDeps(){-  renderTabs();+  document.getElementById('tabbar').innerHTML='';   // global scope tabs live on Home now   document.getElementById('tagfilter').style.display='none';+  const _sr=document.getElementById('sortrow'); if(_sr)_sr.style.display='none';   document.getElementById('q').placeholder='filter components…';   document.getElementById('toolbar').style.display='flex';   const q=qval(),el=rowsEl();el.innerHTML='';-  if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}+  // Circuits carry their BOM in the block model (adom-block.json), not as resolved+  // component pages: render that as the dependency list when there are no page deps.+  if(!curDeps.length && curBom.length){ renderBom(el,q); updateToolbar(); return; }+  if(!curDeps.length){el.innerHTML='<div class="deps-none">This project lists <b>no component dependencies</b> — its BOM and library can’t resolve. Add the components it uses.</div>';updateToolbar();return;}   const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))     .filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));   const groups={};@@ -929,7 +1584,7 @@ function renderDeps(){   });   updateToolbar(); }-function backToBoards(){+function backToBoards(){ setSubject('','');   boardSlug=null;curDeps=[];document.getElementById('q').value='';   const view=document.getElementById('view');view.classList.remove('split');   document.getElementById('component-view').innerHTML='';@@ -937,7 +1592,7 @@ function backToBoards(){   document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';   render(); }-function backToTop(){+function backToTop(){ setSubject('','');   projectSlug=null; boardSlug=null; curDeps=[]; projectBoards=[];   document.getElementById('q').value='';   document.getElementById('view').classList.remove('split');@@ -949,6 +1604,7 @@ function backToTop(){ // its boards in the left pane and shows a simple overview (3D · description · // activity) on the right; clicking a board opens it normally (board → components). async function openProject(slug){+  document.getElementById('workspace').classList.remove('home');   // leave the gallery, interior takes over   projectSlug=slug; boardSlug=null; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;   renderProjectOverview(slug);   rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards…</div>';@@ -995,9 +1651,27 @@ function fillProjectInfo(slug,log){   const lg=document.getElementById('bo-log'); if(!lg)return;   lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>'; }+// Render a circuit's BOM (from its block model) as the dependency list. Each row is+// a part: "qty× value", with LCSC + footprint; it links to a wiki component page when+// one was resolved, otherwise it's an informative (non-clickable) BOM line.+function renderBom(el,q){+  const rows=curBom.filter(p=>{const s=[(p.value||''),(p.mpn||''),(p.lcsc||''),(p.footprint||'')].join(' ').toLowerCase();return !q||s.includes(q);});+  const h=document.createElement('div');h.className='cathead';h.textContent=`Bill of materials · ${rows.length} part${rows.length===1?'':'s'}`;el.appendChild(h);+  rows.forEach(p=>{+    const d=document.createElement('div');d.className='row bomrow'+(p.page?'':' nolink');+    const title=(p.value||p.mpn||'part');+    const qty=p.qty>1?`<span class="bom-qty">${p.qty}×</span>`:'';+    const refs=(p.refs&&p.refs.length)?` <span class="bom-refs">${p.refs.join(', ')}</span>`:'';+    const meta=[p.lcsc?`LCSC ${p.lcsc}`:'',p.footprint||''].filter(Boolean).join(' · ');+    d.innerHTML=`<div class="mpn">${qty}${title}${refs}</div><div class="sub">${meta}${p.page?'':' · not on wiki'}</div>`;+    if(p.page){ d.onclick=()=>openBoard(p.page.split('/').pop()); } else { d.style.cursor='default'; }+    el.appendChild(d);+  });+} function renderProjectBoards(){-  renderTabs();+  document.getElementById('tabbar').innerHTML='';   // global scope tabs live on Home now   document.getElementById('tagfilter').style.display='none';+  const _sr=document.getElementById('sortrow'); if(_sr)_sr.style.display='none';   document.getElementById('toolbar').style.display='none';   const qbox=document.getElementById('q'); qbox.style.display=''; qbox.placeholder='filter boards…';   const q=qval(),el=rowsEl();el.innerHTML='';@@ -1028,7 +1702,9 @@ async function openPart(slug,el){   v.innerHTML='<div id="hint" style="margin-top:6vh">loading '+slug+'…</div>';   const p=await fetch('api/part/'+slug).then(r=>r.json());   if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}-  const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';+  // component 3D via the SAME wiki viewer as boards (adom-3d-viewer-babylon9),+  // so a part looks identical here and on its wiki page.+  const glb=p.glb?`<div class="body embed">${wikiViewer3d(slug)}</div>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';   let rows=(p.layers||[]).map(l=>`<tr>       <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>       <td><span class="badge">${l.kicad}</span></td>@@ -1043,7 +1719,7 @@ async function openPart(slug,el){         <div class="body embed" id="fpbody"><iframe class="embed-frame" src="part-embed/${slug}/footprint" title="footprint" loading="lazy"></iframe></div></div>       <div class="card"><h3>3D Model</h3>${glb}</div>     </div>-    <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>+    <div class="stacktitle">Footprint layer stackup, what each element becomes per EDA tool</div>     <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>     <tbody>${rows}</tbody></table>`;   // Only the 3D "no model" fallback body is pannable now; symbol/footprint are@@ -1092,33 +1768,142 @@ function makePannable(box){ document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();}; // a <select> of the user's libraries; if the current value isn't among them // (e.g. a project-only lib) it's added so nothing is silently dropped.+// Never renders a DEAD control: with no libraries detected it says so (and the modal+// offers a retry), and with no guess it shows an explicit "pick a library" placeholder+// rather than pre-selecting whichever library happened to be first.+// Is this library one of KiCad's BUNDLED ones rather than the user's own? Reported by+// the server (it knows the ${KICAD*_DIR} / install-path entries it filtered out).+function isStockLib(name,k){+  if(!name)return false;+  const list=(k==='symLib'?userLibs.stockSymLibs:userLibs.stockFpLibs)||[];+  return list.includes(name);+} function libSelect(libs,sel,i,k){-  const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}</option>`:'';-  return `<select class="libsel" data-i="${i}" data-k="${k}">${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}</select>`;+  if(!libs||!libs.length)+    return `<select class="libsel nolibs" data-i="${i}" data-k="${k}" disabled><option>${libsGaveUp?'no libraries detected':'detecting your libraries…'}</option></select>`;+  // `sel` may name a library that isn't one of the user's PERSONAL ones — a part+  // already in their library commonly points at a stock KiCad footprint library+  // (Resistor_SMD). Keep it as the selected option rather than dropping it, and+  // HIGHLIGHT it so "this is KiCad's, not yours" is obvious at a glance.+  const stock=isStockLib(sel,k);+  const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}${stock?' — KiCad default':''}</option>`:'';+  const ph=sel?'':`<option value="" selected>— pick a library —</option>`;+  const mk=`<option value="__new__">+ new library…</option>`;+  const cls='libsel'+(sel?'':' unset')+(stock?' stock':'');+  const tip=stock?` data-tip="${sel} is one of KiCad's built-in libraries, not one of yours"`:'';+  return `<select class="${cls}" data-i="${i}" data-k="${k}"${tip}>${ph}${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}${mk}</select>`;+}+// Create a brand-new symbol/footprint library on the user's machine and select it.+// `applyAll` fills every row still missing one, since a new manufacturer library is+// almost always wanted for the whole group that triggered creating it.+async function newLibraryFlow(kind,rowIndex){+  const what=kind==='symLib'?'symbol':'footprint';+  const name=prompt(`Name for the new ${what} library\n\nA ${what} library will be created on your machine and registered with KiCad.`, '');+  if(name===null)return false;+  if(!name.trim()){toast('A library name is required','info');return false;}+  toast(`Creating ${name.trim()} …`,'info');+  try{+    const r=await fetch(`api/create-library?kind=${kind==='symLib'?'sym':'fp'}&name=`+encodeURIComponent(name.trim()));+    const d=await r.json();+    if(!d||!d.ok){toast((d&&d.error)||'Could not create the library','error');return false;}+    if(d.symLibs)userLibs.symLibs=d.symLibs;+    if(d.fpLibs)userLibs.fpLibs=d.fpLibs;+    if(curPlan[rowIndex])curPlan[rowIndex][kind]=d.name;+    curPlan.forEach(p=>{if(!p[kind])p[kind]=d.name;});+    toast(`Created ${d.name} at ${d.path}`,'success');+    if(typeof curRedraw==='function')curRedraw();+    return true;+  }catch(e){toast('Could not create the library','error');return false;} }+// Nothing is delivered until every row has a real target library, so a missing guess+// can never silently become "whatever was first in the list".+function planReady(){return curPlan.length>0&&curPlan.every(p=>p.symLib&&p.fpLib&&(p.symName||'').trim());}+function updateGoState(){+  const go=document.getElementById('modal-go');if(!go)return;+  const ok=planReady();go.disabled=!ok;+  setTip(go, ok?'':(!libCount()?'Your KiCad libraries haven\'t been detected yet':(curPlan.some(p=>!(p.symName||'').trim())?'Every component needs a symbol name':'Pick a target library for every component first')));+}+function setTip(el,tip){if(tip)el.setAttribute('data-tip',tip);else el.removeAttribute('data-tip');} function previewTable(plan,editable){   // project-only: green = being added, white/keep = already in the project library   const st=p=>!projChecked?'':((projDiff.add&&projDiff.add.includes(p.slug))?'addcell':((projDiff.keep&&projDiff.keep.includes(p.slug))?'keepcell':''));   const cell=(p,i)=>editable     ? `<td>${libSelect(userLibs.symLibs,p.symLib,i,'symLib')}</td><td>${libSelect(userLibs.fpLibs,p.fpLib,i,'fpLib')}</td>`-    : `<td><span class="lib ${st(p)}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}">${p.fpLib}.pretty</span></td>`;-  let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?` <span class="inlibtag">✓ ${inl.lib||'in your library'}</span>`:''}</td><td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');-  // project-only: symbols in the library no longer on the board — flagged for deletion (red)+    : `<td><span class="lib ${st(p)}${isStockLib(p.symLib,'symLib')?' stock':''}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}${isStockLib(p.fpLib,'fpLib')?' stock':''}">${p.fpLib}.pretty</span></td>`;+  // what this part will be CALLED inside the user's library — editable, because the+  // name a component page ships with isn't always what they want on their shelf+  const nameCell=(p,i)=>editable+    ? `<td><input class="symname${p.symName&&p.symNameDefault&&p.symName!==p.symNameDefault?' edited':''}" data-i="${i}" value="${(p.symName||'').replace(/"/g,'&quot;')}" placeholder="${symNamesLoading?'loading…':'symbol name'}" spellcheck="false"></td>`+    : `<td><span class="lib">${p.symName||''}</span></td>`;+  // the green "already in your library" tag names BOTH libraries it was found in, so+  // the footprint side is visibly resolved rather than looking like an unfilled guess+  const foundTag=inl=>{+    const tag=(n,k)=>n?`<span class="${isStockLib(n,k)?'stocktag':''}">${n}</span>`:'';+    const parts=[tag(inl.lib,'symLib'),tag(inl.fpLib,'fpLib')].filter(Boolean);+    return `<span class="inlibtag">✓ ${parts.length?parts.join(' · '):'in your library'}</span>`;};+  let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?' '+foundTag(inl):''}</td>${nameCell(p,i)}<td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');+  // Parts whose manufacturer has no library of its own (a Samsung cap when the+  // footprint libraries are per-manufacturer) can't be guessed — rather than make the+  // user set them one at a time, offer to fill every remaining row in one go.+  const bulk=editable?bulkSetRow(plan):'';+  // project-only: symbols in the library no longer on the board, flagged for deletion (red)   if(!editable && projChecked && projDiff.remove && projDiff.remove.length){     const lib=(plan[0]&&plan[0].symLib)||'library';-    rows+=projDiff.remove.map(n=>`<tr class="rmrow"><td>${n} <span class="rmtag">flagged for deletion</span></td><td>—</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');+    rows+=projDiff.remove.map(n=>`<tr class="rmrow"><td>${n} <span class="rmtag">flagged for deletion</span></td><td>-</td><td>-</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');   }-  return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`;+  return bulk+`<table><thead><tr><th>Component</th><th>Symbol name in your library</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`;+}+// "Set all remaining" control, shown only while some row still has no target library.+function bulkSetRow(plan){+  const needSym=plan.filter(p=>!p.symLib).length, needFp=plan.filter(p=>!p.fpLib).length;+  if(!needSym&&!needFp)return '';+  const opts=libs=>`<option value="">— set all remaining to —</option>`+libs.map(l=>`<option value="${l.name}">${l.name}</option>`).join('')+`<option value="__new__">+ new library…</option>`;+  const sym=needSym&&userLibs.symLibs.length?`<span class="bulkitem">${needSym} symbol${needSym>1?'s':''} <select class="libsel" id="bulk-symLib">${opts(userLibs.symLibs)}</select></span>`:'';+  const fp=needFp&&userLibs.fpLibs.length?`<span class="bulkitem">${needFp} footprint${needFp>1?'s':''} <select class="libsel" id="bulk-fpLib">${opts(userLibs.fpLibs)}</select></span>`:'';+  if(!sym&&!fp)return '';+  return `<div class="bulkset">Still unassigned: ${sym}${fp}</div>`;+}+function wireBulkSets(){+  ['symLib','fpLib'].forEach(k=>{+    const s=document.getElementById('bulk-'+k); if(!s)return;+    s.onchange=async()=>{ if(!s.value)return;+      if(s.value==='__new__'){ s.value=''; await newLibraryFlow(k,0); return; }+      curPlan.forEach(p=>{if(!p[k])p[k]=s.value;});+      if(typeof curRedraw==='function')curRedraw(); };+  });+}+// the symbol name each part ships with (slug → name), from /api/symbol-names, so the+// editable name column starts at the REAL default rather than a guess+let symNames={}, symNamesLoading=false, symNameEdits={};+function loadSymbolNames(slugs,cb){+  const want=slugs.filter(s=>!(s in symNames));+  if(!want.length){if(cb)cb();return;}+  symNamesLoading=true; if(cb)cb();+  fetch('api/symbol-names?slugs='+encodeURIComponent(want.join(',')))+    .then(r=>r.json()).then(m=>{Object.assign(symNames,m||{});})+    .catch(()=>{})+    .finally(()=>{symNamesLoading=false;+      curPlan.forEach(p=>{if(!p.symName&&symNames[p.slug]){p.symName=symNames[p.slug];p.symNameDefault=symNames[p.slug];}});+      if(cb)cb();});+}+// wire the editable symbol-name inputs to the live plan+function wireSymNames(){+  document.querySelectorAll('#modal-preview .symname').forEach(inp=>{+    inp.oninput=()=>{const p=curPlan[+inp.dataset.i]; if(!p)return;+      p.symName=inp.value; symNameEdits[p.slug]=inp.value;+      inp.classList.toggle('edited',!!p.symNameDefault&&inp.value!==p.symNameDefault);+      updateGoState();};+  }); } // parts already in the user's libraries (slug → {by, lib}), from /api/in-library let inLib={}, inLibLoading=false, libChecked=false; function setLibStatus(){ const el=document.getElementById('modal-status'); if(!el)return;   if(inLibLoading){ el.className='loading'; el.textContent='Searching your libraries…'; }-  else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done — '+(n?n+' already in your libraries':'none already present'); }+  else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done, '+(n?n+' already in your libraries':'none already present'); }   else { el.className=''; el.textContent=''; } } function checkInLib(slugs,cb){ inLibLoading=true; libChecked=false; setLibStatus(); if(cb)cb();   // The library scan runs shell commands on the user's machine via the bridge; if it's-  // still going after 10s the bridge likely lacks shell permissions — surface that.+  // still going after 10s the bridge likely lacks shell permissions, surface that.   const warn=setTimeout(()=>{ const el=document.getElementById('modal-status'); if(el&&inLibLoading){ el.className='loading'; el.innerHTML='Searching your libraries… <span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span>'; } },10000);   fetch('api/in-library?slugs='+encodeURIComponent(slugs.join(','))).then(r=>r.json())     .then(m=>{ clearTimeout(warn); inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })@@ -1128,7 +1913,7 @@ let projDiff={}, projLoading=false, projChecked=false; function setProjStatus(){ const el=document.getElementById('modal-status'); if(!el)return;   if(projLoading){ el.className='loading'; el.textContent='Checking your project library…'; }   else if(projChecked){ const a=(projDiff.add||[]).length, r=(projDiff.remove||[]).length;-    el.className='done'; el.textContent = projDiff.exists ? `✓ Existing project library found — ${a} to add, ${r} to remove` : `No project library on disk yet — ${a} will be created`; }+    el.className='done'; el.textContent = projDiff.exists ? `✓ Existing project library found, ${a} to add, ${r} to remove` : `No project library on disk yet, ${a} will be created`; }   else { el.className=''; el.textContent=''; } } function checkProjectDiff(board,dest,cb){ if(!board||!dest){projDiff={};projChecked=false;setProjStatus();if(cb)cb();return;}   projLoading=true; projChecked=false; setProjStatus(); if(cb)cb();@@ -1136,14 +1921,28 @@ function checkProjectDiff(board,dest,cb){ if(!board||!dest){projDiff={};projChec     .then(r=>r.json()).then(d=>{projDiff=d||{};projLoading=false;projChecked=true;setProjStatus();if(cb)cb();})     .catch(()=>{projLoading=false;projChecked=true;setProjStatus();if(cb)cb();}); } // wire the per-component library dropdowns to update the live plan-function wireLibSels(){document.querySelectorAll('#modal-preview .libsel').forEach(s=>s.onchange=()=>{if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;});}+function wireLibSels(){+  document.querySelectorAll('#modal-preview .libsel[data-i]').forEach(s=>{+    const prev=s.value;+    s.onchange=async()=>{+      if(s.value==='__new__'){ s.value=prev; if(!await newLibraryFlow(s.dataset.k,+s.dataset.i)){ if(typeof curRedraw==='function')curRedraw(); } return; }+      if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;+      s.classList.toggle('unset',!s.value);+      updateGoState();+    };+  });+  updateGoState();+} function buildPlan(slugs,projectLib,projName){   return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);-    return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:fpLibFor(c)};});+    const dn=symNames[s]||'';+    const cur=(s in symNameEdits)?symNameEdits[s]:dn;+    return {slug:s,title:c.title||s,type:t,symName:cur,symNameDefault:dn,+            symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:fpLibFor(c)};}); } let modalGo=null; const showModal=()=>document.getElementById('modal').style.display='flex';-const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();};+const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;curRedraw=null;const b=document.getElementById('applybar');if(b)b.remove();}; document.getElementById('modal-cancel').onclick=hideModal; // click the dim backdrop (outside the box) to close, same as Cancel document.getElementById('modal').addEventListener('click',e=>{ if(e.target.id==='modal')hideModal(); });@@ -1151,7 +1950,9 @@ document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();}; function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});} // recommended destination parents for delivered projects (editable in the box) function openAddLib(){-  const slugs=[...selected]; if(!slugs.length)return;+  const beL=boardSlug&&blockedEda(boardSlug); if(beL){ toast(`Library delivery for ${beL} components is coming soon`,'info'); return; }+  const slugs=[...selected]; if(!slugs.length){ toast('Select components below to add them to your library','info'); return; }+  symNameEdits={};   curPlan=buildPlan(slugs,false);   document.getElementById('modal-dest').innerHTML='';   document.getElementById('modal-title').textContent='Add to Library';@@ -1159,26 +1960,45 @@ function openAddLib(){   document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;   const go=document.getElementById('modal-go');   inLib={};-  const drawP=()=>{document.getElementById('modal-preview').innerHTML=previewTable(curPlan,true);wireLibSels();};-  selOpt('eda','eda',v=>{if(v==='altium'){document.getElementById('modal-preview').innerHTML='<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>';go.disabled=true;}else{drawP();go.disabled=false;}});-  drawP(); checkInLib(curPlan.map(p=>p.slug),drawP);-  go.disabled=false;go.textContent='Yes, add it';+  // re-guess from the CURRENT userLibs, so a modal opened before the libraries+  // arrived fills itself in as soon as they do (see redrawLibSelects).+  const drawP=()=>{+    curPlan.forEach(p=>{const c=bySlug[p.slug];if(!c)return;+      if(!p.symLib)p.symLib=symLibFor(c); if(!p.fpLib)p.fpLib=fpLibFor(c);});+    curPlan.forEach(p=>{const h=inLib[p.slug];if(h){if(h.lib)p.symLib=h.lib;if(h.fpLib)p.fpLib=h.fpLib;}});+    document.getElementById('modal-preview').innerHTML=libsBanner()+previewTable(curPlan,true);+    wireLibSels();wireBulkSets();wireSymNames();wireLibRetry();+  };+  curRedraw=drawP;+  selOpt('eda','eda',v=>{if(v==='altium'){document.getElementById('modal-preview').innerHTML='<div class="note">Altium add-to-library isn\'t wired up yet, pick KiCad for now.</div>';go.disabled=true;curRedraw=null;}else{curRedraw=drawP;drawP();}});+  drawP(); loadSymbolNames(curPlan.map(p=>p.slug),drawP); checkInLib(curPlan.map(p=>p.slug),drawP);+  go.textContent='Yes, add it'; updateGoState();   modalGo=()=>doApply(curPlan,'kicad',false);   showModal(); }+// Shown in the modal when the user's libraries aren't available, with a retry that+// re-probes the bridge — the old build just rendered empty dropdowns and said nothing.+function libsBanner(){+  if(libCount())return '';+  return `<div class="note" style="border-color:#f0a81c">${libsGaveUp+    ? 'Your KiCad libraries could not be read from your machine. Check that Adom Desktop is connected and has shell permissions, then '+    : 'Detecting your KiCad libraries on your machine… '}<a href="#" id="libretry" style="color:#4ec9d9">retry now</a>.</div>`;+}+function wireLibRetry(){const a=document.getElementById('libretry');if(a)a.onclick=e=>{e.preventDefault();libsGaveUp=false;loadLibraries();redrawLibSelects();};} let fbPrefetch=null; function openAddProj(){+  const beP=boardSlug&&blockedEda(boardSlug); if(beP){ toast(`Project delivery for ${beP} boards is coming soon`,'info'); return; }   // 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';+  let mode='personal'; symNameEdits={};   document.getElementById('modal-title').textContent='Add Project to my Computer';-  document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;+  document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s), symbols & footprints resolved so it opens clean`;   document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;-  // deliver-to: two boxes — an existing parent DIRECTORY (with path autosuggest)-  // and the project folder NAME — joined by the OS separator into the full path.+  // deliver-to: two boxes, an existing parent DIRECTORY (with path autosuggest)+  // and the project folder NAME, joined by the OS separator into the full path.   // deliver-to: a "Browse files" button pops a native folder dialog on the user's   // machine (via the bridge); once picked it shows the path. Then / + project name.   document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><button type="button" id="destbrowse" class="browsebtn" title="choose a folder on your computer">Browse files</button><span class="destsep">${env.sep}</span><input id="destname" spellcheck="false" autocomplete="off" placeholder="project folder name"></div>`;@@ -1191,10 +2011,11 @@ function openAddProj(){     const editable=mode==='personal';     curPlan=buildPlan(slugs,mode==='project',projName);     if(editable)curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}});-    document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> project library is created/updated — <span style="color:#4ade80">green</span> = added, white = already in the library, <span style="color:#f87171">red</span> = removed.':'Pick which of your libraries each symbol + footprint goes into. Parts already in your libraries are marked; keep the library to skip, or change it to add a copy there.'}</div>`+previewTable(curPlan,editable);-    if(editable)wireLibSels();+    document.getElementById('modal-preview').innerHTML=(editable?libsBanner():'')+`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> project library is created/updated, <span style="color:#4ade80">green</span> = added, white = already in the library, <span style="color:#f87171">red</span> = removed.':'Pick which of your libraries each symbol + footprint goes into. Parts already in your libraries are marked; keep the library to skip, or change it to add a copy there.'}</div>`+previewTable(curPlan,editable);+    if(editable){wireLibSels();wireBulkSets();wireSymNames();wireLibRetry();} else {const go=document.getElementById('modal-go');if(go)go.disabled=false;}     modalGo=()=>doApply(curPlan,'kicad',mode==='project',boardSlug,destPath());   };+  curRedraw=draw;   // 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); } };   // An in-app folder browser (native OS dialogs can't render from the detached@@ -1205,14 +2026,14 @@ function openAddProj(){     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();+  document.getElementById('modal-go').textContent='Yes, add the project';+  inLib={}; projDiff={}; draw(); loadSymbolNames(slugs,draw); runCheck(); showModal(); } async function doApply(plan,eda,project,board,dest){   const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';   const opts=document.getElementById('modal-opts');   const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},-    body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));+    body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib,symName:(p.symName||'').trim()}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));   if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}   // live status bar above the preview   let bar=document.getElementById('applybar');@@ -1233,6 +2054,8 @@ async function doApply(plan,eda,project,board,dest){         (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')+         ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');       setTimeout(hideModal,pf.length||sk.length||(r.errors&&r.errors.length)?4200:1600);+      if(r.errors&&r.errors.length) toast(r.errors.length+' issue'+(r.errors.length===1?'':'s')+' while adding, see the dialog','error');+      else toast('Added '+(r.count||0)+' symbol'+(r.count===1?'':'s')+' to your libraries'+(pf.length?', delivered '+pf.length+' project file'+(pf.length===1?'':'s'):''),'success');     }   },350); }@@ -1241,6 +2064,18 @@ document.getElementById('btn-addproj').onclick=openAddProj;  document.getElementById('q').oninput=render; document.getElementById('refresh').onclick=()=>loadList(true);+// Version + update check: sets the app-info version and, when the wiki has a newer+// release, reveals the amber "Update available" badge (with the new version in it).+fetch('api/update').then(r=>r.json()).then(d=>{+  const el=document.getElementById('ai-ver');+  if(el&&d&&d.current)el.textContent='v'+d.current+(d.update&&d.latest?` · v${d.latest} available`:'');+  if(d&&d.update&&d.latest){+    const b=document.getElementById('update-badge'), t=document.getElementById('update-badge-txt');+    if(b){ b.classList.add('show'); b.title=`You're on v${d.current}. v${d.latest} is on the wiki — click to open the page.`;+      if(t)t.textContent=`Update to v${d.latest}`;+      if(d.url)b.href=d.url; }+  }+}).catch(()=>{}); loadList(); </script> </body>
src/manage.html.bakadded+1212
@@ -0,0 +1,1212 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Adom Project Manager</title>+<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>+<style>+  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}+  *{box-sizing:border-box}+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+  #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}+  #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}+  #list h1 small{color:var(--mut);font-weight:400}+  #refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}+  #refresh:hover{background:#243049;border-color:var(--acc)}+  #crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}+  #crumb a{color:var(--acc);text-decoration:none}+  #crumb a:hover{text-decoration:underline}+  #crumb .cactive{color:var(--ink);font-weight:600}+  .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+  .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}+  .row .row-tags{margin-top:4px}+  .row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}+  .tag.kind{text-transform:capitalize}+  .tag.kind.board{background:#16324f;color:#79b8ff}+  .tag.kind.molecule{background:#463307;color:#f0a81c}+  .tag.kind.project{background:#0e3b36;color:#2dd4bf}+  .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.project{background:#0e3b36;color:#2dd4bf}+  .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}+  .pubback{cursor:pointer}+  .pubback:hover{color:var(--ink)}+  .orgrow{display:flex;align-items:center;gap:11px}+  .orgrow .orgicon{width:36px;height:36px;flex:none;color:#2dd4bf;opacity:.9;padding:6px;box-sizing:border-box;background:#1a1f2b;border-radius:8px}+  .orgrow .orgicon-img{width:36px;height:36px;flex:none;border-radius:8px;object-fit:cover;background:#1a1f2b}+  .orgrow .orgname{font-weight:600;color:var(--ink)}+  .orgrow .orgmeta{color:var(--mut);font-size:12.5px}+  .orgrow .scorebadge{margin-left:0}+  .scorebadge{font-size:10px;font-weight:700;color:#f0a81c;background:#463307;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px}+  .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)}+  .tabs .tab.active{color:var(--ink);border-bottom-color:var(--acc);font-weight:600}+  .row .star{margin-right:6px;color:#f5c518;font-size:13px;line-height:1}+  .pubsearch{margin-top:8px}+  .pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}+  .pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}+  .pubsearch input:focus{outline:none;border-color:var(--acc)}+  .pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}+  .pubsearch button:hover{filter:brightness(1.1)}+  .row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}+  #toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}+  #toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}+  #toolbar .tbtns{display:flex;gap:7px}+  #toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}+  #toolbar button:hover{border-color:var(--acc);background:#22304a}+  #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}+  .modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}+  .modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}+  .modal .opt.sel{border-color:var(--acc);background:#1a2740}+  .modal table{width:100%;margin:0 0 6px}+  .modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}+  .modal-actions{display:flex;gap:10px;justify-content:flex-end}+  .modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}+  .modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}+  .modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}+  .modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}+  #modal-dest{margin:0 0 16px}+  #modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}+  #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%}+  .libsel:focus{outline:none;border-color:var(--acc)}+  .libsel option{background:#262c36;color:var(--ink)}+  .inlib td{color:#4ade80}+  .inlib .lib{color:#4ade80}+  .inlibtag{display:block;margin-top:2px;color:#4ade80;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}+  .rmrow td{color:#f87171}+  .rmtag{color:#f87171;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}+  .lib.addcell{color:#4ade80}+  .lib.keepcell{color:var(--ink)}+  .lib.rmcell{color:#f87171}+  #modal-status{font-size:12px;color:#c9d1d9;padding:7px 10px;border-radius:6px;background:#161b22;border:1px solid var(--edge);margin:2px 0 12px;position:relative;overflow:hidden}+  #modal-status:empty{display:none}+  #modal-status.done{color:#4ade80;border-color:rgba(74,222,128,.35)}+  #modal-status.loading::after{content:'';position:absolute;left:-30%;bottom:0;height:2px;width:30%;background:var(--acc);animation:barslide 1.1s ease-in-out infinite}+  @keyframes barslide{to{left:100%}}+  #modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}+  #modal-dest select{min-width:200px}+  #modal-dest input{flex:1;font:12px ui-monospace,monospace}+  #modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}+  #refresh.spin{animation:sp .6s linear infinite}+  @keyframes sp{to{transform:rotate(360deg)}}+  #q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}+  #rows{overflow:auto;flex:1}+  .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}+  .row:hover{background:#1e222b}+  .row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}+  .row .mpn{font-weight:600}+  .row .sub{color:var(--mut);font-size:12px}+  #view{flex:1;overflow:hidden;padding:0;display:flex;flex-direction:column;min-width:0;position:relative}+  #cv-close{display:none;position:absolute;right:14px;top:calc(50% + 10px);z-index:6;background:#1a2130;border:1px solid var(--edge);color:var(--ink);width:27px;height:27px;border-radius:6px;cursor:pointer;font-size:14px;line-height:1}+  #view.split #cv-close{display:block}+  #cv-close:hover{border-color:var(--acc);background:#22304a}+  #board-overview{flex:1;overflow:auto;min-height:0}+  #component-view{display:none;min-height:0;overflow:auto;padding:16px}+  #view.split #board-overview{flex:0 0 50%}+  #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-hero{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto;border-radius:8px}+  .bo-desc{color:var(--ink);font-size:13px;line-height:1.55}+  .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}+  .bo-3d-wrap .bo-fs-abs{top:10px;right:10px}+  .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;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)}+  .bo-hd-ctl{display:flex;align-items:center;gap:8px}+  .bo-fs{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:27px;height:24px;font-size:13px;cursor:pointer;line-height:1;padding:0}+  .bo-fs:hover{border-color:var(--acc)}+  .bo-view.maxed{position:fixed;inset:0;z-index:300;background:var(--bg);margin:0;padding:16px;display:flex;flex-direction:column}+  .bo-view.maxed .bo-viewbody{flex:1;height:auto}+  .bo-view.maxed .bo-view-hd{margin-bottom:12px}+  .bo-viewbody{height:260px;background:#161b22;border:1px solid var(--edge);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;touch-action:none}+  .bo-viewbody svg{width:100%;height:100%;display:block}+  .bo-na{color:#8b93a3;font-size:13px;text-align:center;padding:20px;line-height:1.5}+  /* In-viewer loading spinner (3D / schematic / PCB): Adom mark + percent below */+  .vload{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;width:100%;gap:10px;background:#0d0f14}+  .vload.ov{position:absolute;inset:0;z-index:5}+  .vload img{width:72px;height:72px;object-fit:contain;image-rendering:auto}+  .vload .vload-pct{color:#9fb0c0;font-size:12px;letter-spacing:.06em;font-variant-numeric:tabular-nums}+  .bo-section{padding:0 20px 20px}+  .bo-section>h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--edge);padding-bottom:6px}+  .bo-log .commit{padding:8px 0;border-bottom:1px solid var(--edge)}+  .bo-log .commit:last-child{border-bottom:none}+  .commit-msg{font-size:13px;color:var(--ink)}+  .commit-meta{font-size:11.5px;color:var(--mut);margin-top:2px}+  .commit-chips{display:flex;gap:5px;flex-wrap:wrap;margin:3px 0}+  .chg{font-size:10px;padding:1px 7px;border-radius:4px;background:#21262e;color:var(--mut);white-space:nowrap}+  .chg.add{color:#4ade80}+  .chg.rm{color:#f87171}+  .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}+  .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}+  .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}+  .card .cardfs{position:absolute;top:7px;right:9px;z-index:4}+  /* fullscreen a component card (its embed sidebar reveals itself past the width breakpoint) */+  .card.maxed{position:fixed;inset:0;z-index:300;margin:0;border-radius:0}+  .card.maxed .body{aspect-ratio:auto;flex:1;min-height:0}+  .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}+  .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}+  .card .body svg{max-width:100%;max-height:100%;will-change:transform}+  /* Interactive adom-symbol / adom-footprint embed (self-contained HTML in an iframe) */+  .card .body.embed{padding:0;cursor:default;background:#0d0f14}+  .embed-frame{width:100%;height:100%;border:0;display:block;background:#0d0f14}+  .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}+  .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}+  .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}+  .chip input{margin:0;cursor:pointer}+  .chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}+  .chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}+  model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}+  h2.title{margin:0 0 14px}+  h2.title small{color:var(--mut);font-weight:400;font-size:14px}+  h2.title .wikibtn{display:inline-block;margin-left:14px;font-size:12px;font-weight:600;vertical-align:2px;background:#16324f;color:#79b8ff;border:1px solid #24466a;border-radius:6px;padding:3px 10px;text-decoration:none;cursor:pointer}+  h2.title .wikibtn:hover{border-color:var(--acc);background:#1a3a5c}+  table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}+  th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--edge);font-size:13px}+  th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}+  tr:last-child td{border-bottom:none}+  .swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}+  .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}+  .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}+  .err{color:#f87171;padding:16px}+  .card .body [data-num]{cursor:pointer}+  .pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}+  rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}+  #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}+  #tip b{color:var(--acc)}+  .bo-embed{width:100%;height:100%;border:0;display:block}+</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">+        <button id="btn-addlib" disabled>Add to Library</button>+        <button id="btn-addproj">Add Project ↓</button>+      </div>+    </div>+    <div id="rows"></div>+  </aside>+  <main id="view">+    <button id="cv-close" onclick="closeComponent()" title="close">✕</button>+    <div id="board-overview"><div id="hint">← pick a board to see it here</div></div>+    <div id="component-view"></div>+  </main>+  <div id="modal" class="modal-overlay" style="display:none"><div class="modal">+    <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>+    <div id="modal-status" class="libstatus"></div>+    <div class="opts" id="modal-opts"></div>+    <div id="modal-dest"></div>+    <div id="modal-preview"></div>+    <div class="sure">Are you sure this is good?</div>+    <div class="modal-actions"><button id="modal-cancel">Cancel</button><button class="primary" id="modal-go">Yes, add it</button></div>+  </div></div>++<script>+const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};+// Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component+// dependencies ▸ the symbol/footprint/3D viewer.+let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';+let projectSlug=null, projectBoards=[]; // when a PROJECT is open: its board deps (project → boards → components)+// the user's machine env (OS + home + default delivery folders), from /api/env+let env={os:'linux',home:'',sep:'/',dest_folders:[]};+// the user's REAL KiCad libraries (name+uri), from /api/libraries; curPlan = the+// components + their chosen target libraries for the open Add modal+let userLibs={symLibs:[],fpLibs:[]}, curPlan=[];+function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; }+const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};+const isProject=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('project');+const isListable=c=>isBoard(c)||isProject(c);+const qval=()=>document.getElementById('q').value.toLowerCase();+const rowsEl=()=>document.getElementById('rows');+function makeRow(slug,title,sub,onclick,tag,sel){+  const d=document.createElement('div');d.className='row';d.dataset.slug=slug;+  const cb=sel?`<input type="checkbox" class="selbox"${selected.has(slug)?' checked':''}>`:'';+  d.innerHTML=`<div class="mpn">${cb}${title}${tag?`<span class="tag">${tag}</span>`:''}</div><div class="sub">${sub}</div>`;+  d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };+  return d;+}+// keywords per component type, used to pre-select the closest of the USER's own+// libraries (discovered from their machine) — never hard-coded personal names.+const TYPE_KEYWORDS={'Capacitors':['cap'],'Resistors':['res'],'MOSFETs':['fet','mos'],'Transistors':['fet','transistor','bjt'],'Inductors':['induct','coil','choke'],'LEDs':['led'],'Diodes':['diode'],'Connectors':['conn'],'Microcontrollers':['mcu','micro'],'Power ICs':['power','regulat','pmic'],'Crystals & Oscillators':['crystal','osc','xtal'],'ICs':['ic','misc'],'Other':['misc']};+const selected=new Set();+// best guess among the user's discovered libraries for a component type+function bestLib(libs,type){const kws=TYPE_KEYWORDS[type]||[];for(const kw of kws){const m=libs.find(l=>l.name.toLowerCase().includes(kw));if(m)return m.name;}return libs[0]?libs[0].name:'';}+function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c));}+function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c));}+function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();}+function updateToolbar(){+  const n=selected.size;+  const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';+  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){ 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'); }+}+function render(){ if(boardSlug)renderDeps(); else if(projectSlug)renderProjectBoards(); else renderBoards(); }+// The Personal/Organization/Public nav lives in a persistent bar (not inside the+// scrolling list), so it's always available — even while a board is open. Clicking+// a tab leaves the current project and returns you to that board list.+function renderTabs(){+  const boards=all.filter(isListable);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me).length;+  const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)).length;+  const defs=[['mine',`Personal · ${mineAll}`],['org',`Organization · ${orgAll}`],['public','Public']];+  const bar=document.getElementById('tabbar');+  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; backToTop(); });+}+// 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=[['project','Project'],['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];+// Render the tag-filter chips into any container (shared by the top bar and the public search).+function fillChips(el){+  if(!el)return;+  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 renderFilterBar(){+  const el=document.getElementById('tagfilter'); if(!el)return;+  // Public page renders its chips BELOW the public search box (inside renderPublic), so hide the top bar there.+  if(boardTab==='public'){ el.style.display='none'; el.innerHTML=''; return; }+  el.style.display=''; fillChips(el);+}+function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }+function isFusionBoard(slug){ const e=boardEdaOf(bySlug[slug]||{slug}); return !!(e&&e.includes('fusion')&&!e.includes('kicad')); }+// 3D orientation is keyed on the GLB's own up-axis stamp (asset.extras.adomMolecule.upAxis),+// NOT the EDA. step2glb --molecule normalizes to Z-up and stamps "z"; glTF/model-viewer is+// Y-up, so a Z-up GLB needs a -90° X tip. Raw (un-pipelined) exports carry no stamp — they're+// already Y-up, so no rotation. This is per-board and correct whether a board went through the+// pipeline or not. The stamp is fetched once per board and cached.+const FUSION_ORIENT='0deg -90deg 0deg';+const upAxisCache={};+async function fetchUpAxis(slug,ver){+  const k=slug+'@'+(ver||'');+  if(k in upAxisCache) return upAxisCache[k];+  try{ const r=await fetch('api/board-upaxis/'+slug+(ver?('?v='+encodeURIComponent(ver)):'')); const d=await r.json(); upAxisCache[k]=d.up_axis||null; }+  catch(e){ upAxisCache[k]=null; }+  return upAxisCache[k];+}+function mvOrientForAxis(up){ return up==='z' ? ` orientation="${FUSION_ORIENT}"` : ''; }+function matchesFilter(c){+  if(!filterTags.size) return true;+  const kindSel=['project','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(); renderFilterBar();+  const boards=all.filter(isListable);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me);+  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.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; within each org, split into projects you contribute to+    // (you're the author) vs everyone else's.+    orgs.forEach(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);+      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);+    });+  }+}+// A board row. Boards you've starred on the wiki show a ★ and pin to the top of+// their list (starred status comes from the wiki API; there's no toggle here).+const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function boardKind(c){const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('project')?'project':t.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=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(()=>{});+}+// 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>';+    // If the folder listing hasn't come back in 5s, the desktop bridge probably can't+    // run shell commands — tell the user what to check instead of spinning forever.+    const warn=setTimeout(()=>{ if(listEl.querySelector('.fb-loading')) listEl.innerHTML='<div class="fb-loading">Still loading…<br><span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span></div>'; },5000);+    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){ clearTimeout(warn); listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }+    clearTimeout(warn);+    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;+  const star=c.starred?`<span class="star" title="starred on the wiki">★</span>`:'';+  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=Array.isArray(c.eda);+  const edaSpan=edaKnown?edaTags(c.eda):`<span class="tag eda" data-eda="${c.slug}"></span>`;+  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=()=>isProject(c)?openProject(c.slug):openBoard(c.slug);+  if(!edaKnown)loadEda(c.slug);+  return d;+}+function sortBoards(list){+  return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase()));+}+// The Public tab. DEFAULT (no search) = a popularity leaderboard: top 10 most+// popular public projects, then top 10 most popular orgs. Typing a query clears+// the leaderboard and shows normal filtered results. Clicking an org drills into+// that org's public projects.+// If an org has no avatar (or the proxy fails), fall back to the generic building glyph.+function orgIconErr(img){ img.outerHTML=ORG_ICON; }+const ORG_ICON='<svg class="orgicon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 21V6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v3h7a1 1 0 0 1 1 1v11H3zm2-2h2v-2H5v2zm0-4h2v-2H5v2zm0-4h2V9H5v2zm0-4h2V7H5v2zm4 12h2v-2H9v2zm0-4h2v-2H9v2zm0-4h2V9H9v2zm0-4h2V7H9v2zm4 12h6v-2h-6v2zm2-4h2v-2h-2v2zm0-4h2v-2h-2v2z"/></svg>';+let pubOrg=null, pubBoards=[];+function renderPublic(boards,el){+  pubBoards=boards.filter(c=>{const v=(c.visibility||'').toLowerCase();return v==='public'||v==='open';});+  const wrap=document.createElement('div');wrap.className='pubsearch';+  wrap.innerHTML=`<div class="pubrow"><input id="pubq" placeholder="search public projects"></div><div id="tagfilter-pub" class="tagfilter"></div><div id="pubresults"></div>`;+  el.appendChild(wrap);+  fillChips(document.getElementById('tagfilter-pub'));   // tag filters sit BELOW the search on the public page+  const pubq=document.getElementById('pubq');pubq.value=pubQuery;+  const doPub=()=>{pubQuery=pubq.value;pubOrg=null;renderPubResults();};+  pubq.oninput=()=>{ pubQuery=pubq.value; pubOrg=null; if(!pubq.value.trim()){pubQuery='';} renderPubResults(); };  // live search; cleared → leaderboard+  pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};+  renderPubResults();+}+function renderPubResults(){+  const pr=document.getElementById('pubresults'); if(!pr)return; pr.innerHTML='';+  const pub=pubBoards;+  if(pubOrg){                                                   // drilled into one org+    const back=document.createElement('div');back.className='cathead pubback';back.innerHTML=`← ${pubOrg} · public projects`;back.onclick=()=>{pubOrg=null;renderPubResults();};pr.appendChild(back);+    sortBoards(pub.filter(c=>c.org===pubOrg)).forEach(c=>pr.appendChild(boardRow(c)));+    return;+  }+  const pq=pubQuery.trim().toLowerCase();+  if(pq){                                                       // normal search+    const m=pub.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));+    if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;}+    sortBoards(m).forEach(c=>pr.appendChild(boardRow(c)));+    return;+  }+  // DEFAULT leaderboard: top 10 popular projects+  const topBoards=[...pub].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,5);+  const h1=document.createElement('div');h1.className='cathead';h1.textContent='Trending';pr.appendChild(h1);+  if(!topBoards.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent='no public projects yet';pr.appendChild(d);}+  topBoards.forEach(c=>{const row=boardRow(c);const m=row.querySelector('.mpn');if(m)m.insertAdjacentHTML('beforeend',` <span class="scorebadge" title="popularity score">▲ ${Math.round(c.score||0)}</span>`);pr.appendChild(row);});+  // top 10 popular organizations (by aggregate score of their public projects)+  const agg={};+  pub.forEach(c=>{const o=c.org;if(o){const a=agg[o]=agg[o]||{score:0,count:0};a.score+=(c.score||0);a.count++;}});+  const topOrgs=Object.entries(agg).sort((a,b)=>b[1].score-a[1].score).slice(0,5);+  if(topOrgs.length){+    const h2=document.createElement('div');h2.className='cathead';h2.textContent='Popular Organizations';pr.appendChild(h2);+    topOrgs.forEach(([org,a],i)=>{+      const d=document.createElement('div');d.className='row orgrow';+      d.innerHTML=`<img class="orgicon-img" src="org-avatar/${encodeURIComponent(org)}" alt="" onerror="orgIconErr(this)"><span class="orgname">${org}</span><span class="orgmeta">· ${a.count} project${a.count>1?'s':''} ·</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span>`;+      d.onclick=()=>{pubOrg=org;renderPubResults();};+      pr.appendChild(d);+    });+  }+}+async function openBoard(slug){+  boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;+  const title=(bySlug[slug]||{}).title||slug;+  document.getElementById('crumb').innerHTML=projectSlug+    ? `<a href="#" onclick="backToTop();return false">Projects</a> / <a href="#" onclick="openProject('${projectSlug}');return false">${(bySlug[projectSlug]||{}).title||projectSlug}</a> / <span class="cactive">${title}</span>`+    : `<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;+  renderBoardOverview(slug);+  rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';+  try{ const r=await fetch('api/board/'+slug).then(r=>r.json());+    if(boardSlug!==slug)return; // navigated back/away while loading — don't paint stale parts+    curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }+  catch(e){ if(boardSlug!==slug)return; curDeps=[]; }+  if(boardSlug!==slug)return;+  renderDeps();+}+// The board overview (top of the right pane): name + author, a 3D view of the+// 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>${_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">${viewerLoaderHTML('loading…')}</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">${viewerLoaderHTML('rendering…')}</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">${viewerLoaderHTML('rendering…')}</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);+  loadEda(slug);+}+function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m]));}+function timeago(ts){+  if(!ts)return'';const s=Math.floor(Date.now()/1000)-ts;+  if(s<60)return'just now';const m=Math.floor(s/60);if(m<60)return m+'m ago';+  const h=Math.floor(m/60);if(h<24)return h+'h ago';const d=Math.floor(h/24);if(d<30)return d+'d ago';+  const mo=Math.floor(d/30);if(mo<12)return mo+'mo ago';return Math.floor(mo/12)+'y ago';+}+function commitHtml(c){+  const chips=[];+  if(c.comp_added)chips.push(`<span class="chg add">+${c.comp_added} component${c.comp_added>1?'s':''}</span>`);+  if(c.comp_removed)chips.push(`<span class="chg rm">−${c.comp_removed} component${c.comp_removed>1?'s':''}</span>`);+  if(c.pcb_changed)chips.push(`<span class="chg">PCB changed</span>`);+  if(c.sch_changed)chips.push(`<span class="chg">schematic changed</span>`);+  const chipsHtml=chips.length?`<div class="commit-chips">${chips.join('')}</div>`:'';+  return `<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div>${chipsHtml}<div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`;+}+function fillBoardInfo(slug,log){+  if(boardSlug!==slug)return;+  const lg=document.getElementById('bo-log');+  if(!lg)return;+  lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+  // enrich each entry with what changed (components +/-, PCB/schematic) — lazily,+  // since it re-fetches each commit's PCB/schematic to diff them+  fetch('api/board-activity/'+slug).then(r=>r.json()).then(d=>{+    if(boardSlug!==slug)return;+    const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');+  }).catch(()=>{});+}+// Red error bar at the top only when the page is actually missing the assets it+// needs to display (checked first via has_assets) — a board that still has its+// KiCad source / 3D model renders fine (live) and gets NO bar even without render/.+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_assets===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 the assets it needs to render (its KiCad board/schematic or 3D model aren't on the page), so its 3D, PCB and schematic won't load. Reupload the ${kind} with the <b>molecule-publish</b> skill.`;+}+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.+  let m={rendering:true};+  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);+  // non-KiCad boards (EAGLE/Fusion, or 3D-only) still show their 3D via the model+  // GLB; the 2D PCB/schematic panes just render "not available" (no KiCad renderer).+  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||{};+  if(!(boardVersion && m.current && boardVersion!==m.current)) return '';+  // Append the version's content hash so the URL is content-addressed — the browser (and+  // model-viewer) can't serve a stale cache entry for this ?v from a different render.+  const e=(m.versions||[]).find(x=>x.version===boardVersion);+  const h=(e&&e.hash)?'&h='+encodeURIComponent(e.hash):'';+  return '?v='+encodeURIComponent(boardVersion)+h;+}+// 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();+  // Fusion/EAGLE renders carry their own colors baked in: the PCB already has the dark+  // editor canvas + pre-dimmed copper pour (so skip dimFills, and match the pane bg to+  // the #282828 canvas the paper-rect strip removes), and the schematic is on white+  // (EAGLE) so the pane goes white — makes it read like it's inside Fusion.+  const fusion=isFusionBoard(slug);+  const schOpt=fusion?{light:true,bg:'#ffffff'}:{light:true};+  const pcbOpt=fusion?{bg:'#282828'}:{dimFills:0.4};+  const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');+  // Instant feedback on a version switch: blank each pane to the loading spinner NOW,+  // before any await, so the previously-selected version's view doesn't linger while+  // the new version's assets fetch.+  if(d3 && m.has_3d){ d3.style.position='relative'; d3.style.background='#0d0f14'; showViewerLoader(d3); }+  if(sch && (m.sheets||[]).length) showViewerLoader(sch);+  if(pcb && m.has_pcb) showViewerLoader(pcb);+  if(d3){+    if(m.has_3d){+      const up=await fetchUpAxis(slug, boardVersion);   // GLB up-axis stamp → per-board orientation+      if(boardSlug!==slug)return;+      d3.style.position='relative'; d3.style.background='#0d0f14';+      d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`++        `<div class="vload ov"><img src="loader.gif" alt=""></div>`;+      const mv=d3.querySelector('model-viewer'), ov=d3.querySelector('.vload.ov'), pct=ov.querySelector('.vload-pct');+      const done=()=>{ if(ov)ov.remove(); };+      // model-viewer streams the GLB and reports detail.totalProgress 0→1.+      mv.addEventListener('progress',e=>{ const p=e.detail&&e.detail.totalProgress; if(p!=null){ setViewerPct(pct,p); if(p>=1)done(); } });+      mv.addEventListener('load',done);+    } else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;+  }+  const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');+  if(sel)sel.style.display='none'; // the interactive schematic viewer shows the whole sheet+  // Interactive views: adom-schematic (schematic) + adom-2dboard (PCB),+  // served as self-contained embeds by the backend /board-view route and iframed here.+  if(sch){+    if(sheets.length){ sch.innerHTML=embedFrame(`board-view/${slug}/sch${q}`,'#ffffff'); }+    else { sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }+  }+  if(boardSlug!==slug)return;+  if(pcb){ if(m.has_pcb){ pcb.innerHTML=embedFrame(`board-view/${slug}/pcb${q}`,'#0d1117'); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+}+// Build an interactive-viewer iframe with a loading spinner that clears on load.+function embedFrame(src,bg){ return `<iframe class="bo-embed" style="background:${bg}" src="${src}" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></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||projectSlug]||{}).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>`;+}+// Loading UI shared by the 3D / schematic / PCB viewers: the Adom mark + a percent below.+function setViewerPct(el,frac){ if(el) el.textContent=Math.round(Math.max(0,Math.min(1,frac))*100)+'%'; }+// Adom-mark spinner markup with a status/percent label below it (default "0%").+function viewerLoaderHTML(label){ return `<div class="vload"><img src="loader.gif" alt=""></div>`; }+// Fill a viewer box with the loading spinner; returns the percent/status <span> to update.+function showViewerLoader(box,label){ box.style.background='#0d0f14'; box.innerHTML=viewerLoaderHTML(label); return box.querySelector('.vload-pct'); }+// Fetch text while streaming download progress into a percent element (Content-Length based;+// falls back to KB counter if the length is unknown, then a plain text() on any stream error).+async function fetchTextProgress(url,pctEl){+  let r; try{ r=await fetch(url); }catch(e){ throw e; }+  if(!r.ok) return await r.text();+  if(!r.body||!r.body.getReader) return await r.text();+  const total=+(r.headers.get('content-length')||0);+  try{+    const reader=r.body.getReader(); let got=0; const chunks=[];+    for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;+      if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }+    if(pctEl&&total>0) setViewerPct(pctEl,1);+    const buf=new Uint8Array(got); let off=0; for(const c of chunks){ buf.set(c,off); off+=c.length; }+    return new TextDecoder().decode(buf);+  }catch(e){ return await r.text(); }+}+// Like fetchTextProgress but returns a Blob (for the project 3D GLB, which we load via a blob URL).+async function fetchBlobProgress(url,pctEl){+  const r=await fetch(url);+  if(!r.ok) return null;+  if(!r.body||!r.body.getReader) return await r.blob();+  const total=+(r.headers.get('content-length')||0);+  try{+    const reader=r.body.getReader(); let got=0; const chunks=[];+    for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;+      if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }+    if(pctEl&&total>0) setViewerPct(pctEl,1);+    return new Blob(chunks,{type:r.headers.get('content-type')||'model/gltf-binary'});+  }catch(e){ return await r.blob(); }+}+// 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+// for the PCB, then enable pan/zoom.+function mountSvg(box,svgText,opt){+  opt=opt||{};+  // parse as real SVG (innerHTML with the XML prolog doesn't expose the <g> tree)+  let svg;+  try{ svg=new DOMParser().parseFromString(svgText,'image/svg+xml').documentElement; }catch(e){}+  if(!svg||String(svg.nodeName).toLowerCase()!=='svg'){box.innerHTML='<div class="bo-na">view not available</div>';return;}+  box.innerHTML=''; box.appendChild(svg);+  box.style.background=opt.bg||(opt.light?'#f4f3ee':'#0d0f14');+  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 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){+    // 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||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';+    });+    // 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((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+    });+  }+  // remove the full-page paper rect (crop to the content on the box background)+  {+    const vb=svg.viewBox.baseVal;+    if(vb)[...svg.querySelectorAll('rect')].forEach(r=>{const w=+r.getAttribute('width')||0,h=+r.getAttribute('height')||0;if(w>=vb.width*0.92&&h>=vb.height*0.92)r.remove();});+  }+  try{const bb=svg.getBBox();if(bb.width&&bb.height){const pad=Math.max(bb.width,bb.height)*0.03;svg.setAttribute('viewBox',`${bb.x-pad} ${bb.y-pad} ${bb.width+2*pad} ${bb.height+2*pad}`);}}catch(e){}+  panZoomSvg(svg);+}+function panZoomSvg(svg){+  const vb=svg.viewBox.baseVal;svg.style.cursor='grab';let drag=null;+  svg.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY};svg.setPointerCapture(e.pointerId);svg.style.cursor='grabbing';});+  svg.addEventListener('pointermove',e=>{if(!drag)return;const r=svg.getBoundingClientRect();vb.x-=(e.clientX-drag.x)*vb.width/r.width;vb.y-=(e.clientY-drag.y)*vb.height/r.height;drag={x:e.clientX,y:e.clientY};});+  const end=()=>{drag=null;svg.style.cursor='grab';};+  svg.addEventListener('pointerup',end);svg.addEventListener('pointercancel',end);+  svg.addEventListener('wheel',e=>{e.preventDefault();const r=svg.getBoundingClientRect();const px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height;const mx=vb.x+px*vb.width,my=vb.y+py*vb.height;const f=e.deltaY<0?0.85:1.18;vb.width*=f;vb.height*=f;vb.x=mx-px*vb.width;vb.y=my-py*vb.height;},{passive:false});+}+// Infer a component's type from its tags + brief + title (most parts carry no+// explicit category, so keywords in the brief are the signal).+const TYPE_RULES=[+  [/microcontroller|\bmcu\b|\bmpu\b|\bsoc\b/,'Microcontrollers'],+  [/regulator|\bldo\b|dc-?dc|buck|boost|power management|\bpmic\b/,'Power ICs'],+  [/mosfet|\bfet\b/,'MOSFETs'],+  [/transistor|\bbjt\b|\bnpn\b|\bpnp\b/,'Transistors'],+  [/\bled\b/,'LEDs'],+  [/diode|schottky|zener|\btvs\b|rectifier/,'Diodes'],+  [/mlcc|capacitor|\bcap\b|tantalum/,'Capacitors'],+  [/resistor|thick film|thin film/,'Resistors'],+  [/inductor|chip coil|\bcoil\b|ferrite|choke|\bbead\b/,'Inductors'],+  [/crystal|oscillator|resonator/,'Crystals & Oscillators'],+  [/connector|header|receptacle|socket|\bjack\b|\busb\b/,'Connectors'],+  [/switch|button|tactile/,'Switches'],+  [/\bfuse\b/,'Fuses'],+  [/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'],+];+const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];+function typeOf(c){+  const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();+  for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;+  return 'Other';+}+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='';+  if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}+  const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))+    .filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));+  const groups={};+  items.forEach(x=>{const t=typeOf(x.c);(groups[t]=groups[t]||[]).push(x);});+  const cats=Object.keys(groups).sort((a,b)=>{const ia=TYPE_ORDER.indexOf(a),ib=TYPE_ORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib)||a.localeCompare(b);});+  cats.forEach(cat=>{+    const h=document.createElement('div');h.className='cathead';h.textContent=`${cat} · ${groups[cat].length}`;el.appendChild(h);+    groups[cat].forEach(x=>{const on=bySlug[x.slug];el.appendChild(makeRow(x.slug,x.c.title||x.slug,on?x.slug:x.slug+' · not on wiki',openPart,null,true));});+  });+  updateToolbar();+}+function backToBoards(){+  boardSlug=null;curDeps=[];document.getElementById('q').value='';+  const view=document.getElementById('view');view.classList.remove('split');+  document.getElementById('component-view').innerHTML='';+  if(projectSlug){ openProject(projectSlug); return; }   // inside a project → back to its board list+  document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';+  render();+}+function backToTop(){+  projectSlug=null; boardSlug=null; curDeps=[]; projectBoards=[];+  document.getElementById('q').value='';+  document.getElementById('view').classList.remove('split');+  document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';+  document.getElementById('component-view').innerHTML='';+  render();+}+// ---- Projects: a page tagged `project` whose deps are BOARDS. Opening one lists+// its boards in the left pane and shows a simple overview (3D · description ·+// activity) on the right; clicking a board opens it normally (board → components).+async function openProject(slug){+  projectSlug=slug; boardSlug=null; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;+  renderProjectOverview(slug);+  rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards…</div>';+  try{ const r=await fetch('api/project/'+slug).then(r=>r.json());+    if(projectSlug!==slug)return;+    projectBoards=(r.boards||[]).map(d=>d.slug);+    fillProjectInfo(slug,r.log);+  }catch(e){ if(projectSlug!==slug)return; projectBoards=[]; }+  if(projectSlug!==slug)return;+  renderProjectBoards();+}+function renderProjectOverview(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>`:'';+  document.getElementById('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 project">project</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">${viewerLoaderHTML('loading…')}</div></div></div>+    <div class="bo-section"><h3>Description</h3><div class="bo-desc" id="bo-desc">${esc(c.brief||'')||'<span class="bo-na" style="text-align:left">no description</span>'}</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>`;+  curBoardMedia={}; renderVersionPill();+  loadProject3d(slug);+}+async function loadProject3d(slug){+  const d3=document.getElementById('bo3d'); if(!d3)return;+  const pct=showViewerLoader(d3);   // Adom mark + percent while the GLB streams down+  try{+    const blob=await fetchBlobProgress('board-glb/'+slug,pct);+    if(projectSlug!==slug)return;+    if(blob){ const up=await fetchUpAxis(slug,null); if(projectSlug!==slug)return; const url=URL.createObjectURL(blob); d3.style.background='#0d0f14'; d3.innerHTML=`<model-viewer src="${url}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`; return; }+  }catch(e){}+  if(projectSlug!==slug)return;+  // no GLB yet → fall back to the page's hero image (proxied server-side, any hero type)+  d3.innerHTML='';+  const img=document.createElement('img'); img.className='bo-hero';+  img.src='board-hero/'+slug;+  img.onerror=()=>{ if(projectSlug===slug)d3.innerHTML='<div class="bo-na">3D not available</div>'; };+  d3.appendChild(img);+}+function fillProjectInfo(slug,log){+  if(projectSlug!==slug)return;+  const lg=document.getElementById('bo-log'); if(!lg)return;+  lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+}+function renderProjectBoards(){+  renderTabs();+  document.getElementById('tagfilter').style.display='none';+  document.getElementById('toolbar').style.display='none';+  const qbox=document.getElementById('q'); qbox.style.display=''; qbox.placeholder='filter boards…';+  const q=qval(),el=rowsEl();el.innerHTML='';+  const title=(bySlug[projectSlug]||{}).title||projectSlug;+  document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToTop();return false">← Projects</a> / <span class="cactive">${title}</span>`;+  const boards=projectBoards.map(s=>bySlug[s]||{slug:s}).filter(c=>!q||c.slug.includes(q)||((c.title||'').toLowerCase().includes(q)));+  if(!boards.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this project lists no boards</div>';return;}+  const h=document.createElement('div');h.className='cathead';h.textContent=`Boards · ${boards.length}`;el.appendChild(h);+  sortBoards(boards).forEach(c=>el.appendChild(boardRow(c)));+}+// Close the bottom-half component view (the ✕), back to just the board overview.+function closeComponent(){+  document.getElementById('view').classList.remove('split');+  document.getElementById('component-view').innerHTML='';+  document.querySelectorAll('.row.sel').forEach(r=>r.classList.remove('sel'));+}+// Maximize a schematic/PCB view to fill the webview page (not the OS screen);+// click the button again to restore.+function toggleFs(btn){+  const view=btn.closest('.bo-view')||btn.closest('.card');   // board viewers OR component cards+  if(view)view.classList.toggle('maxed');+}+async function openPart(slug,el){+  document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));+  if(el)el.classList.add('sel');+  const view=document.getElementById('view');view.classList.add('split');+  const v=document.getElementById('component-view');+  v.innerHTML='<div id="hint" style="margin-top:6vh">loading '+slug+'…</div>';+  const p=await fetch('api/part/'+slug).then(r=>r.json());+  if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}+  const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';+  let rows=(p.layers||[]).map(l=>`<tr>+      <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>+      <td><span class="badge">${l.kicad}</span></td>+      <td><span class="badge">${l.altium}</span></td></tr>`).join('');+  const wc=bySlug[slug]||{}; const wowner=wc.org||wc.owner||'';+  const wikiUrl=wowner?`https://wiki.adom.inc/${wowner}/${encodeURIComponent(slug)}`:'';+  v.innerHTML=`+    <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small>${wikiUrl?`<a class="wikibtn" href="${wikiUrl}" target="_blank" rel="noopener">Open on wiki ↗</a>`:''}</h2>+    <div class="cols">+      <div class="card"><h3>Schematic Symbol</h3><button class="bo-fs cardfs" title="fullscreen (shows the pin panel)" onclick="toggleFs(this)">⛶</button><div class="body embed"><iframe class="embed-frame" src="part-embed/${slug}/symbol" title="schematic symbol" loading="lazy"></iframe></div></div>+      <div class="card"><h3>Footprint</h3><button class="bo-fs cardfs" title="fullscreen (shows the layers panel)" onclick="toggleFs(this)">⛶</button>+        <div class="body embed" id="fpbody"><iframe class="embed-frame" src="part-embed/${slug}/footprint" title="footprint" loading="lazy"></iframe></div></div>+      <div class="card"><h3>3D Model</h3>${glb}</div>+    </div>+    <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>+    <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>+    <tbody>${rows}</tbody></table>`;+  // Only the 3D "no model" fallback body is pannable now; symbol/footprint are+  // self-contained embed iframes with their own pan/zoom + pin-info/layers panels.+  v.querySelectorAll('.card .body:not(.embed)').forEach(makePannable);+}++// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and+// name/description in a tooltip. Pins/pads carry data-num + data-name.+function setupHighlight(v){+  let tip=document.getElementById('tip');+  if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}+  const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));+  v.querySelectorAll('.card .body').forEach(box=>{+    box.addEventListener('pointermove',e=>{+      const hit=e.target.closest('[data-num]');+      clear(box);+      if(hit){+        hit.classList.add('pinhi');+        const num=hit.dataset.num, nm=hit.dataset.name;+        const kind=hit.closest('#fpbody')?'Pad':'Pin';+        tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;+        tip.style.display='block';+        tip.style.left=(e.clientX+14)+'px';+        tip.style.top=(e.clientY+14)+'px';+      } else { tip.style.display='none'; }+    });+    box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});+  });+}++// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag+// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.+function makePannable(box){+  const svg=box.querySelector('svg');+  if(!svg) return;+  let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;+  const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};+  box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};+  box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};+  box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};+  box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};+  box.ondblclick=()=>{tx=ty=0;scale=1;apply();};+}+// ---- selection controls + add-to-library / add-project modal ----+document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};+// a <select> of the user's libraries; if the current value isn't among them+// (e.g. a project-only lib) it's added so nothing is silently dropped.+function libSelect(libs,sel,i,k){+  const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}</option>`:'';+  return `<select class="libsel" data-i="${i}" data-k="${k}">${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}</select>`;+}+function previewTable(plan,editable){+  // project-only: green = being added, white/keep = already in the project library+  const st=p=>!projChecked?'':((projDiff.add&&projDiff.add.includes(p.slug))?'addcell':((projDiff.keep&&projDiff.keep.includes(p.slug))?'keepcell':''));+  const cell=(p,i)=>editable+    ? `<td>${libSelect(userLibs.symLibs,p.symLib,i,'symLib')}</td><td>${libSelect(userLibs.fpLibs,p.fpLib,i,'fpLib')}</td>`+    : `<td><span class="lib ${st(p)}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}">${p.fpLib}.pretty</span></td>`;+  let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?` <span class="inlibtag">✓ ${inl.lib||'in your library'}</span>`:''}</td><td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');+  // project-only: symbols in the library no longer on the board — flagged for deletion (red)+  if(!editable && projChecked && projDiff.remove && projDiff.remove.length){+    const lib=(plan[0]&&plan[0].symLib)||'library';+    rows+=projDiff.remove.map(n=>`<tr class="rmrow"><td>${n} <span class="rmtag">flagged for deletion</span></td><td>—</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');+  }+  return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`;+}+// parts already in the user's libraries (slug → {by, lib}), from /api/in-library+let inLib={}, inLibLoading=false, libChecked=false;+function setLibStatus(){ const el=document.getElementById('modal-status'); if(!el)return;+  if(inLibLoading){ el.className='loading'; el.textContent='Searching your libraries…'; }+  else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done — '+(n?n+' already in your libraries':'none already present'); }+  else { el.className=''; el.textContent=''; } }+function checkInLib(slugs,cb){ inLibLoading=true; libChecked=false; setLibStatus(); if(cb)cb();+  // The library scan runs shell commands on the user's machine via the bridge; if it's+  // still going after 10s the bridge likely lacks shell permissions — surface that.+  const warn=setTimeout(()=>{ const el=document.getElementById('modal-status'); if(el&&inLibLoading){ el.className='loading'; el.innerHTML='Searching your libraries… <span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span>'; } },10000);+  fetch('api/in-library?slugs='+encodeURIComponent(slugs.join(','))).then(r=>r.json())+    .then(m=>{ clearTimeout(warn); inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })+    .catch(()=>{ clearTimeout(warn); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); }); }+// project-only mode: diff the board against the project library on disk (add/keep/remove)+let projDiff={}, projLoading=false, projChecked=false;+function setProjStatus(){ const el=document.getElementById('modal-status'); if(!el)return;+  if(projLoading){ el.className='loading'; el.textContent='Checking your project library…'; }+  else if(projChecked){ const a=(projDiff.add||[]).length, r=(projDiff.remove||[]).length;+    el.className='done'; el.textContent = projDiff.exists ? `✓ Existing project library found — ${a} to add, ${r} to remove` : `No project library on disk yet — ${a} will be created`; }+  else { el.className=''; el.textContent=''; } }+function checkProjectDiff(board,dest,cb){ if(!board||!dest){projDiff={};projChecked=false;setProjStatus();if(cb)cb();return;}+  projLoading=true; projChecked=false; setProjStatus(); if(cb)cb();+  fetch('api/project-diff?board='+encodeURIComponent(board)+'&dest='+encodeURIComponent(dest))+    .then(r=>r.json()).then(d=>{projDiff=d||{};projLoading=false;projChecked=true;setProjStatus();if(cb)cb();})+    .catch(()=>{projLoading=false;projChecked=true;setProjStatus();if(cb)cb();}); }+// wire the per-component library dropdowns to update the live plan+function wireLibSels(){document.querySelectorAll('#modal-preview .libsel').forEach(s=>s.onchange=()=>{if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;});}+function buildPlan(slugs,projectLib,projName){+  return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);+    return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:fpLibFor(c)};});+}+let modalGo=null;+const showModal=()=>document.getElementById('modal').style.display='flex';+const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();};+document.getElementById('modal-cancel').onclick=hideModal;+// click the dim backdrop (outside the box) to close, same as Cancel+document.getElementById('modal').addEventListener('click',e=>{ if(e.target.id==='modal')hideModal(); });+document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();};+function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});}+// recommended destination parents for delivered projects (editable in the box)+function openAddLib(){+  const slugs=[...selected]; if(!slugs.length)return;+  curPlan=buildPlan(slugs,false);+  document.getElementById('modal-dest').innerHTML='';+  document.getElementById('modal-title').textContent='Add to Library';+  document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → pick which of your libraries each goes into`;+  document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;+  const go=document.getElementById('modal-go');+  inLib={};+  const drawP=()=>{document.getElementById('modal-preview').innerHTML=previewTable(curPlan,true);wireLibSels();};+  selOpt('eda','eda',v=>{if(v==='altium'){document.getElementById('modal-preview').innerHTML='<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>';go.disabled=true;}else{drawP();go.disabled=false;}});+  drawP(); checkInLib(curPlan.map(p=>p.slug),drawP);+  go.disabled=false;go.textContent='Yes, add it';+  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';+  document.getElementById('modal-title').textContent='Add Project to my Computer';+  document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;+  document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;+  // deliver-to: two boxes — an existing parent DIRECTORY (with path autosuggest)+  // and the project folder NAME — joined by the OS separator into the full path.+  // deliver-to: a "Browse files" button pops a native folder dialog on the user's+  // machine (via the bridge); once picked it shows the path. Then / + project name.+  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><button type="button" id="destbrowse" class="browsebtn" title="choose a folder on your computer">Browse files</button><span class="destsep">${env.sep}</span><input id="destname" spellcheck="false" autocomplete="off" placeholder="project folder name"></div>`;+  const destname=document.getElementById('destname'); destname.value=boardSlug;+  let chosenDir=''; const browse=document.getElementById('destbrowse');+  const destPath=()=>chosenDir?chosenDir.replace(/[\/\\]+$/,'')+env.sep+destname.value.trim():'';+  // project directory to diff against: the browsed folder, else the default Projects dir, + the name+  const projPath=()=>((chosenDir||destFolders()[0]||'').replace(/[\/\\]+$/,''))+env.sep+(destname.value.trim()||boardSlug);+  const draw=()=>{+    const editable=mode==='personal';+    curPlan=buildPlan(slugs,mode==='project',projName);+    if(editable)curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}});+    document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> project library is created/updated — <span style="color:#4ade80">green</span> = added, white = already in the library, <span style="color:#f87171">red</span> = removed.':'Pick which of your libraries each symbol + footprint goes into. Parts already in your libraries are marked; keep the library to skip, or change it to add a copy there.'}</div>`+previewTable(curPlan,editable);+    if(editable)wireLibSels();+    modalGo=()=>doApply(curPlan,'kicad',mode==='project',boardSlug,destPath());+  };+  // 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); } };+  // 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();+}+async function doApply(plan,eda,project,board,dest){+  const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';+  const opts=document.getElementById('modal-opts');+  const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},+    body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));+  if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}+  // live status bar above the preview+  let bar=document.getElementById('applybar');+  if(!bar){bar=document.createElement('div');bar.id='applybar';bar.className='note';opts.parentNode.insertBefore(bar,opts.nextSibling);}+  const poll=setInterval(async()=>{+    let p; try{p=await fetch('api/apply-progress').then(r=>r.json());}catch(e){return;}+    const pct=p.total?` · ${p.current}/${p.total}`:'';+    go.textContent=(p.done?'✓ ':'')+(p.stage||'Working…').replace(/…$/,'').slice(0,22)+'…';+    bar.innerHTML=`<b>${p.stage||'Working…'}</b>${pct}`;+    if(p.done){+      clearInterval(poll);+      const r=p.result||{}, pf=(r.project&&r.project.files||[]);+      go.textContent=`✓ Added ${r.count||0}`;+      const folder=(r.project&&r.project.folder||'').replace(env.home||'~','~');+      const sk=(r.skipped||[]);+      bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`++        (sk.length?`<br><span style="color:#8b93a3">Skipped ${sk.length} already in your libraries (e.g. ${sk.slice(0,2).map(s=>s.component+' · '+s.by).join(', ')})</span>`:'')++        (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')++        ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');+      setTimeout(hideModal,pf.length||sk.length||(r.errors&&r.errors.length)?4200:1600);+    }+  },350);+}+document.getElementById('btn-addlib').onclick=openAddLib;+document.getElementById('btn-addproj').onclick=openAddProj;++document.getElementById('q').oninput=render;+document.getElementById('refresh').onclick=()=>loadList(true);+loadList();+</script>+</body>+</html>
src/viewer3d.htmladded+85
@@ -0,0 +1,85 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>3D Viewer</title>+<style>+  *{margin:0;padding:0;box-sizing:border-box}+  html,body{width:100%;height:100%;overflow:hidden;background:#0d1117;color:#c9d1d9;font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif}+  #viewer-wrap{position:absolute;inset:0}+  #preload-msg{position:absolute;inset:0;z-index:1;pointer-events:none;display:flex;align-items:center;justify-content:center;color:#6e7681;font-size:13px}+  #error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;background:#0d1117;z-index:200;padding:16px;text-align:center}+  #error.visible{display:flex}+  #error h2{color:#f85149;margin-bottom:8px;font-size:15px}+  #error p{color:#8b949e;font-size:13px;max-width:400px}+</style>+</head>+<body>+  <div id="viewer-wrap"></div>+  <div id="preload-msg">Loading 3D viewer…</div>+  <div id="error"><h2>3D not available</h2><p id="error-msg"></p></div>+  <script>+    // Project Manager-hosted copy of the wiki's adom-3d-viewer (Babylon 9). It loads the SAME+    // bundle + Draco decoder, but proxied same-origin through Project Manager's /v3d/ route+    // (Project Manager reverse-proxies the wiki's PUBLIC /static/vendor/adom-3d-viewer-babylon9/),+    // and it loads the MODEL from Project Manager's own authenticated board-glb route. Everything+    // is same-origin from the browser's view, so no cross-site session cookie is needed —+    // which is exactly why a PRIVATE board renders here when the wiki's cross-origin iframe+    // (SameSite=Lax cookie stripped) 404s. Same Babylon viewer, works public + private.+    window.__V3D_BASE = new URL('v3d/', location.href).href;+    window.__viewerLoadFailed = function(msg){+      var e=document.getElementById('error'),em=document.getElementById('error-msg'),l=document.getElementById('preload-msg');+      if(em)em.textContent=msg||'The 3D viewer could not be loaded.'; if(e)e.classList.add('visible'); if(l)l.style.display='none';+    };+  </script>+  <script type="module" src="v3d/adom-3d-viewer-babylon9.esm.js" onerror="window.__viewerLoadFailed && window.__viewerLoadFailed()"></script>+  <script type="module">+    (function(){+      var q=new URLSearchParams(location.search);+      var slug=q.get('slug')||'';+      var v=q.get('v')||'';+      var up=(q.get('up')||'z').toLowerCase();+      var loadingEl=document.getElementById('preload-msg');+      var loaded=false;+      var timer=setTimeout(function(){+        if(loaded)return;+        window.__viewerLoadFailed(typeof window.Adom3DViewerBabylon9==='undefined'+          ? 'The 3D viewer could not be loaded.'+          : 'The 3D model is taking too long to load.');+      },25000);+      var Viewer=window.Adom3DViewerBabylon9;+      if(window.__viewerFailed || typeof Viewer==='undefined'){ clearTimeout(timer); window.__viewerLoadFailed('The 3D viewer could not be loaded.'); return; }+      // Self-host the Draco decoder (proxied) so KHR_draco_mesh_compression GLBs decode.+      try{+        if(window.BABYLON && window.BABYLON.DracoCompression){+          var B=window.__V3D_BASE+'draco/';+          window.BABYLON.DracoCompression.Configuration={decoder:{+            wasmUrl:B+'draco_wasm_wrapper_gltf.js',+            wasmBinaryUrl:B+'draco_decoder_gltf.wasm',+            fallbackUrl:B+'draco_decoder_gltf.js'+          }};+        }+      }catch(e){}+      // Model comes from Project Manager's own authenticated route (same-origin).+      var glbUrl='board-glb/'+encodeURIComponent(slug)+(v?('?v='+encodeURIComponent(v)):'');+      try{+        // zUp:true for our pipeline GLBs (step2glb --molecule stamps Z-up); ?up=y flips it+        // for a raw Y-up export. Matches the wiki viewer's zUp:true default.+        var viewer=Viewer.init(document.getElementById('viewer-wrap'),{zUp:up!=='y',showViewCube:true,showGround:true});+        if(loadingEl)loadingEl.style.display='none';+        viewer.loadModel(glbUrl).then(function(){+          loaded=true; clearTimeout(timer); viewer.frameModel();+          try{ if(viewer.toggleAxes) viewer.toggleAxes('world',false); }catch(e){}+        }).catch(function(err){+          clearTimeout(timer);+          window.__viewerLoadFailed('Error: '+((err&&err.message)||'the GLB could not be loaded'));+        });+      }catch(err){+        clearTimeout(timer);+        window.__viewerLoadFailed('The 3D viewer failed to start: '+((err&&err.message)||'unknown error'));+      }+    })();+  </script>+</body>+</html>
uninstall.sh+3−1
@@ -1,3 +1,5 @@ #!/bin/bash set -euo pipefail-rm -f "${HOME}/.local/bin/adom-project-manager"+rm -f "${HOME}/.local/bin/adom-project-manager" "${HOME}/.local/bin/adom-nucleus"; echo "Uninstalled adom-project-manager"+rm -f "${HOME}/.local/bin/glb_optimize.mjs"+rm -rf "${HOME}/.claude/skills/publish-board"
vendor/altium-codec/Cargo.toml+1−1
@@ -2,7 +2,7 @@ name = "altium-codec" version = "0.1.0" edition = "2021"-description = "Trimmed vendor (model + KiCad reader/writer only) of adom/adom-lbr's altium-codec, for adom-project-manager."+description = "Trimmed vendor (model + KiCad reader/writer only) of adom/adom-lbr's altium-codec, for adom-nucleus."  [lib] path = "src/lib.rs"
vendor/altium-codec/src/lib.rs+1−1
@@ -1,4 +1,4 @@-//! altium-codec — TRIMMED vendor for adom-project-manager.+//! altium-codec — TRIMMED vendor for adom-nucleus. //! //! Contains only the canonical part model + the KiCad reader/writer that the //! Manager uses (`import_kicad`, `export_kicad_symbol/footprint`, the model