app
Adom Project Manager
Public Made by Adomby adom
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 Manag
← Commit history
Revert adom-nucleus -> adom-project-manager; repoint viewers to adom-2dboard / adom-schematic
17 files changed
+3661−708
Cargo.lock+1−1Cargo.toml+2−2README.md+54−7SKILL.md+38−3build.rs+22docs/board-view.pngdocs/projects.pnginstall.sh+24−2package.json+78−10release.sh+87src/main.rs+1051−513src/manage.html+1002−167src/manage.html.bak+1212src/viewer3d.html+85uninstall.sh+3−1vendor/altium-codec/Cargo.toml+1−1vendor/altium-codec/src/lib.rs+1−1Cargo.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 &`+ -Kept as a deprecated alias (depends on `adom/adom-nucleus`). Please migrate.+## What it does --+- **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.+ +- **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(¶m("kind"), ¶m("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(®);+ // 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'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,'"')}" 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,'&').replace(/</g,'<').replace(/"/g,'"');+ 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=>({'&':'&','<':'<','>':'>'}[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