app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
← Commit history
Sync current source (open-source, up to date)
34 files changed
+5679−477
.gitignore+6Cargo.lock+293Cargo.toml+3−3INVENTORY.md+86README.md+44−79demo/captcha-paced.sh+78demo/chip-fetcher-0.1.3-demo-script.md+75demo/record.sh+117install.sh+1−1selftest.cjs+142skills/CONTRIBUTING.md+68skills/SKILL.md+84skills/sites/digikey/SKILL.md+63skills/sites/manufacturer/SKILL.md+47skills/sites/mouser/SKILL.md+30skills/sites/snapeda/SKILL.md+51skills/sites/ti/SKILL.md+19src/cli.rs+455−219src/creds.rs+1−1src/enrich.rs+217src/hints.rs+120−47src/import.rs+57−1src/inventory.rs+41−32src/main.rs+2src/projects.rs+1−1src/provenance.rs+73src/pup.rs+678−73src/serve.rs+268−4src/sources/mod.rs+1src/sources/ti.rs+43src/thumbnails.rs+87−8src/ui/index.html+2222src/validate.rs+10−8tool-map.html+196.gitignoreadded+6@@ -0,0 +1,6 @@+target/+library/+incoming/+archive/+*.log+.DS_Store
Cargo.lockadded+293@@ -0,0 +1,293 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-chip-fetcher"+version = "1.2.24"+dependencies = [+ "anyhow",+ "clap",+ "serde",+ "serde_json",+ "tiny_http",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml+3−3@@ -1,11 +1,11 @@ [package]-name = "chip-fetcher"-version = "1.2.23"+name = "adom-chip-fetcher"+version = "1.2.24" edition = "2021" description = "Download manufacturer-grade chip CAD bundles (STEP, KiCAD symbol/footprint, datasheets) into a structured library by driving Chrome via pup." [[bin]]-name = "chip-fetcher"+name = "adom-chip-fetcher" path = "src/main.rs" [dependencies]
INVENTORY.mdadded+86@@ -0,0 +1,86 @@+# chip-fetcher library inventory++Generated: Sat May 2 12:09:44 UTC 2026++## Summary++- **18 fully-bundled chips** (STEP + symbol + footprint) — wiki-publishable+- **9 stubs** (incomplete — see Gaps section)+- **162.8 MB total**++## Vendor coverage++| Vendor | Fully-bundled | Stubs |+|---|---:|---:|+| TI | 9 | 4 |+| Espressif | 2 | 0 |+| NXP | 2 | 0 |+| ADI | 1 | 0 |+| Microchip | 1 | 0 |+| Lattice Semiconductor | 1 | 0 |+| Renesas Electronics | 1 | 0 |+| ST | 1 | 0 |+| Bosch | 0 | 2 |+| Diodes | 0 | 1 |+| WAGO | 0 | 1 |+| Nordic | 0 | 1 |++## Fully-bundled chips (publish-ready)++| MPN | Vendor | Package | PDFs | info.json | Source | Size |+|---|---|---|---:|---|---|---:|+| AD7124-8BBCPZ | ADI | ? | 1 | — | — | 1.9 MB |+| ADS1115IDGSR | TI | ? | 1 | — | — | 2.8 MB |+| ADS131M04IPBSR | TI | ? | 1 | — | — | 3.6 MB |+| ATSAMD51J20A-AUT | Microchip | ? | 0 | — | — | 1.1 MB |+| BQ25798RQMR | TI | ? | 0 | — | — | 1.4 MB |+| DRV8316RRGFR | TI | ? | 1 | — | — | 5.1 MB |+| ESP32-S3-WROOM-1-N4 | Espressif | ? | 3 | — | — | 18.6 MB |+| ESP32-S3FN8 | Espressif | ? | 2 | — | — | 16.5 MB |+| ICE40UP5K-SG48ITR | Lattice Semiconductor | QFN-48 (SG48) | 18 | ✓ | mouser-cse-fallback | 39.2 MB |+| INA226AIDGSR | TI | ? | 1 | — | — | 1.6 MB |+| LM358APE4 | TI | ? | 1 | — | — | 4.2 MB |+| MCF8316A1RRYR | TI | ? | 1 | — | — | 7.1 MB |+| MCXN947VNLT | NXP | ? | 16 | — | — | 24.8 MB |+| PCA9685 | NXP | ? | 1 | — | — | 2.3 MB |+| R7FA4M1AB3CFP | Renesas Electronics | LQFP-100 | 3 | ✓ | mouser-ecad-model-fallback | 7.4 MB |+| TPS54302DDC | TI | ? | 1 | — | — | 2.3 MB |+| TPS62840YBGR | TI | ? | 0 | — | — | 0.0 MB |+| VL53L8CX | ST | ? | 1 | — | — | 5.1 MB |++## Stubs / partials (NOT publish-ready)++| MPN | Vendor | step | sym | mod | pdf | Action |+|---|---|:-:|:-:|:-:|:-:|---|+| BME688 | Bosch | ✗ | ✗ | ✗ | ✓ | Bosch — datasheet only (Bosch doesn't self-host CAD); fetch via Mouser ECAD-Model with approval |+| BMI270 | Bosch | ✗ | ✗ | ✗ | ✓ | Bosch — datasheet only; same as BME688 |+| DMG2305UX-7 | Diodes | ✗ | ✗ | ✗ | ✓ | Diodes Inc MOSFET — datasheet only; fetch via Mouser ECAD-Model |+| LM358DR | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only variant of full sibling — likely DELETE |+| MSP430G2553IPW20 | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only TI part — fetch via UL (mfr-direct) |+| TPS62840DLCR | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only variant of YBGR — likely DELETE (covered by sibling) |+| TPS65987DDH | TI | ✗ | ✗ | ✗ | ✓ | USB-C PD; TI page didn't expose UL link for this part — try Mouser ECAD-Model |+| WAGO-2601-3105 | WAGO | ✓ | ✗ | ✗ | ✗ | connector — STEP only, intentional (no sym/mod for connectors yet) |+| nRF52840 | Nordic | ✗ | ✗ | ✗ | ✗ | empty stub — DELETE or fetch (Nordic mfr-direct + UL) |++## Recommended cleanup before wiki-publishing++**Delete (covered by full sibling):**+- LM358DR (we have LM358APE4 fully bundled)+- TPS62840DLCR (we have TPS62840YBGR fully bundled — different package though, keep if you want both DSBGA + WSON variants)++**Fetch CAD to complete:**+- MSP430G2553IPW20 (TI, easy via UL deep-link)+- TPS65987DDH (TI USB-C PD)+- nRF52840 (Nordic; check Nordic site first per the rule)+- BME688 / BMI270 (Bosch; will need Mouser ECAD-Model fallback with explicit approval)+- DMG2305UX-7 (Diodes MOSFET)++**Keep as-is:**+- WAGO-2601-3105 (connector; STEP only is intentional — connectors don't fit the chip schema)++## Provenance audit++Most fully-bundled chips don't yet have `info.json`. We need to retrofit one per chip before publishing so chip-pads downstream knows trust tier:++- **16 chips need info.json** retrofit: AD7124-8BBCPZ, ADS1115IDGSR, ADS131M04IPBSR, ATSAMD51J20A-AUT, BQ25798RQMR, DRV8316RRGFR, ESP32-S3-WROOM-1-N4, ESP32-S3FN8, INA226AIDGSR, LM358APE4, MCF8316A1RRYR, MCXN947VNLT, PCA9685, TPS54302DDC, TPS62840YBGR, VL53L8CX+- **2 chips already have info.json**: ICE40UP5K-SG48ITR, R7FA4M1AB3CFP
README.md+44−79@@ -1,96 +1,61 @@-> # ⚠️ This page has moved->-> **`chip-fetcher` is now [`adom-chip-fetcher`](https://wiki.adom.inc/john/adom-chip-fetcher)** — we put the Adom name in front of all our tools.->-> Install: `adompkg install john/adom-chip-fetcher`->-> Kept only as a pointer; no longer maintained.-- # chip-fetcher -**Download manufacturer-grade chip CAD bundles — 3D STEP + symbol & footprint + Fusion `.lbr` + datasheet — into a structured library, and open or ship each part straight to your EDA tool.**--chip-fetcher drives a real Chrome session through a manufacturer-first sourcing ladder (Manufacturer → SnapEDA → Mouser → DigiKey → Arrow → Ultra Librarian → Component Search Engine), pulls the CAD bundle for a part, validates it, and lands it in `library/<MPN>/`. A live dashboard then turns every chip into a launch-pad for the rest of the Adom EDA family. Standalone Rust — no Node, no gallia, no local CAD install (rendering goes through the shared `service-kicad`).--## The dashboard--Your whole parts library at a glance — every chip a rich card with live thumbnails, status, stock and price, and one-tap launchers.--- **Projects** — filter the library to a working set with a project chip (`Library 53`, `nRF54L15`, …); the dashboard opens on the project you last activated.-- **Working-on heartbeat** — while a part is being fetched/validated, its card shows a live status badge ("rendering thumbnails", "walking the ladder…") and the view auto-scrolls to it.-- **Status at a glance** — FULL/STUB, STEP·KICAD·ALTIUM·FUSION presence, lifecycle, **stock + price** (Mouser), and the validation badge per card.--### The chip thumbnail picker + source carats----Each card's **SYM** and **FP** tile carries a **▾ source carat**. When a part was pulled from more than one place (SnapEDA · Ultra Librarian · Component Search · ds2sf-from-datasheet), tap the carat to **switch source** — the tile re-renders to that vendor's actual drawing and the choice sticks. The **3D** tiles (Z↑ / Y↑) are an orientation picker, with a ▾ for all six face-up axes. Pick the part, the source, and the orientation you trust.--### Open in / Send to — the detail panel----Selecting a chip fills a **tabbed panel** (Actions · Details · Files — so the long file list never dominates):--- **Open in** — `adom-symbol`, `adom-footprint`, `adom-lbr`, `adom-3D`, `chipsmith`, `ds2sf extraction`. Opens in **your** browser via `window.open`, so it works on a phone, in a pup window, or a Hydrogen webview — and a toast tells you exactly how it opened.-- **Send to** — KiCad (native symbol + footprint), Fusion 360 (real `.lbr`), Altium / OrCAD (`.lbr` download + import steps; native exporters on the roadmap).+Download manufacturer-grade chip CAD bundles (STEP + KiCAD symbol + KiCAD footprint + datasheet + design-help PDFs) into a structured library by driving Chrome via [pup](https://github.com/adom-inc/adom-desktop). -### Zoom — the focus view+The defining rule: **fetch from the manufacturer's own website first** — only fall back to Mouser, DigiKey, or Component Search Engine when the manufacturer doesn't self-host CAD, and only with explicit user approval. -+## What it does -Tap **🔍** on any card to zoom it full-screen (with an ✕ to return) — the focus view carries the part's tiles, carats, **Open in**, and **Send to**, so on a phone everything for one chip is in one place.+```bash+chip-fetcher fetch TPS62840 # opens the TI page → UL deep-link → drives the form → auto-pulls+chip-fetcher pull '<windows-path>' # pulls a downloaded zip from the user's desktop+chip-fetcher import path/to/zip --mpn # absorbs a downloaded file into library/<MPN>/+chip-fetcher list # shows what's in library/ vs missing per slot+chip-fetcher login <source> # opens a vendor login page in the chip-fetcher pup profile+chip-fetcher install # deploys SKILL.md to ~/.claude/skills/chip-fetcher/+``` -## The source-site skill pack+After running, each chip lands in: -chip-fetcher's per-vendor navigation knowledge lives **in this repo** as an extensible skill pack (`skills/`) — one installable skill per site, documenting how to reach the CAD download **and how to recover when a site changes or blocks automation**. The crawler is the binary; the *how-to-drive-each-site* layer is community-owned.+```+library/<MPN>/+├── <MPN>.step # rich manufacturer STEP, named PRODUCTs (Body, ThermalPin, PinsArrayLR, PinsArrayTB)+├── <MPN>.kicad_sym+├── <MPN>.kicad_mod+├── <MPN>.pdf # datasheet+├── <MPN>-errata-*.pdf+├── <MPN>-appnote-*.pdf+├── <MPN>-hwguide.pdf+└── info.json # provenance trail (mfr-direct vs mouser-fallback)+``` -### Skills shipped today+## Verified vendors -| Site | Skill | Covers |+| Vendor | Path | Notes | |---|---|---|-| **SnapEDA / SnapMagic** | `sites/snapeda` | login + multi-format download (the canonical template) |-| **DigiKey** | `sites/digikey` | product-page CAD **+ the "pup is blocked" recovery playbook** |-| **Mouser** | `sites/mouser` | CAD + the authoritative stock/price/lifecycle source |-| **Ultra Librarian / CSE** | `sites/ultralibrarian` | the format-picker flow many vendor "ECAD model" buttons embed |-| **Manufacturer (generic)** | `sites/manufacturer` | the top-rung approach for any maker's product page |-| **Texas Instruments** | `sites/ti` | ti.com CAD section (Ultra Librarian embed + native STEP) |-| **STMicroelectronics** | `sites/st` | st.com CAD Resources tab behind myST |-| **NXP** | `sites/nxp` | nxp.com Design Resources + export-control click-through |--### Sites we still need skills for — **volunteer one!**--The community uses far more sources than we've covered. **If a site you use isn't listed above, you can add it** — these are the gaps we'd most love help on:--- **Manufacturers:** Microchip · Analog Devices · Infineon · onsemi · Renesas · Nordic · Espressif · Diodes Inc · Vishay · Murata · TDK · Bosch Sensortec-- **Distributors / aggregates:** Arrow · LCSC / EasyEDA · Newark/Farnell · RS · Octopart · SamacSys/Component Search Engine-- **Anything else** the electronics community pulls CAD from.+| Texas Instruments | `vendor.ultralibrarian.com/TI/embedded/...` | mfr-direct via UL |+| STMicroelectronics | `vendor.ultralibrarian.com/stmicro/embedded/...` | mfr-direct via UL |+| Analog Devices | `vendor.ultralibrarian.com/adi/embedded/...` | mfr-direct via UL |+| Microchip | `vendor.ultralibrarian.com/microchip/...` | mfr-direct via UL (iframe) |+| NXP | `nxp.com` (BXL) + `nxp.componentsearchengine.com` (STEP) | mfr-direct |+| Espressif | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys |+| Lattice | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys |+| Renesas | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys | -If chip-fetcher gets stuck on a site, or you know a site it doesn't cover, the best contribution you can make is a **site skill** — and the day a vendor changes its layout or blocks automation, the freshest skill wins.+See [SKILL.md](https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher) for the full skill documentation, vendor URL patterns, and the failure-mode notes captured from real Adom-user feedback. -### How to make + contribute a site skill+## Install -1. **Copy the template** — `sites/snapeda/SKILL.md`. Fill in the five sections: **Reach** (the product-page URL pattern), **Gate** (login/region/cookie wall + which creds key), **Download flow** (the exact path to the CAD bundle), **Catch** (what the artifact is + where it lands), and **When it breaks** (the failure signatures + the recovery playbook — the highest-value part).-2. **Give it real trigger words** (the vendor name, "blocked", "captcha", the file types) so the AI surfaces it at the right moment.-3. **Contribute it** — two paths, the maintainer's call per contribution (full detail in `skills/CONTRIBUTING.md`):- - **(A) PR into the pack** *(vetted — the default)* — open a PR adding `sites/<vendor>/SKILL.md`; the maintainer reviews + merges and it ships with the pack.- - **(B) Linked external skill** *(trusted — opt-in)* — own the skill in **your own wiki repo** (you `adompkg publish` updates whenever you like) and PR a one-line **link** into the pack; it self-updates without a maintainer round-trip — ideal for fast-moving vendor sites.-4. **Security:** "getting past a block" means navigating the site as a human would (the right warm profile, human pacing, the path the site's own assistant suggests) — **never** defeating CAPTCHAs or security controls. Credentials come from `chip-fetcher creds`, never inline.--Either way, your AI — and everyone else's — gets smarter the next time that site comes up.--## Part of the Adom EDA family+```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-fetcher/chip-fetcher \+ -o /usr/local/bin/chip-fetcher && chmod +x /usr/local/bin/chip-fetcher && chip-fetcher install+``` -chip-fetcher is the hub; it hands parts to the standalone apps it ships alongside — **adom-symbol**, **adom-footprint**, **adom-lbr**, and **ds2sf**.+## Companion specs -## Run it+Two adom-desktop fix-it specs live in [scripts/](scripts/): -```bash-chip-fetcher serve # the live dashboard (AI-drivable: /api/library, /api/open, …)-chip-fetcher fetch <MPN> # source a part into the library-chip-fetcher sources # print the canonical sourcing ladder-```+- `adom-desktop-pullfile-speed-prompt.md` — streaming binary WS frames + HTTP fallback so 50+ MB files (NXP reference manuals) don't time out+- `adom-desktop-credential-vault-prompt.md` — DPAPI/Keychain-encrypted credential vault hooked into `puppeteer.page.authenticate()` so basic-auth popups never appear -MIT, contributions welcome — start with a **site skill** (above).+Paste either into Claude Code on the desktop to implement.
demo/captcha-paced.shadded+78@@ -0,0 +1,78 @@+#!/bin/bash+# Captcha demo — explicit human-paced version.+# Replicates cf-ul-fast.sh phase-2's tile-click + verify but with sleep gaps so+# the recording captures (a) each tile getting a checkmark, (b) Verify being+# clicked, (c) green-check appearing, (d) form unlocking + download starting.+set -u+SESS="${CF_SESSION:-chip-fetcher-demo}"+TAB="tab-1"+URL="https://vendor.ultralibrarian.com/TI/embedded/?gpn=LM358&package=D&pin=8&sid=lit2ddb1769f694931cc606e8&c=1"++post_act() {+ curl -s -X POST http://127.0.0.1:8780/api/activity \+ -H "Content-Type: application/json" \+ -d "{\"mpn\":\"$1\",\"stage\":\"$2\"}" > /dev/null+}++# 1. Navigate fresh + click I'm-not-a-robot+post_act LM358 "navigating UL embedded"+adom-desktop browser_navigate "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"url\":\"$URL\"}" >/dev/null 2>&1+sleep 5++# Pre-set the form (which CAD formats to download — needed for Submit later)+adom-desktop browser_eval "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"expr\":\"(()=>{var labels=['STEP','KiCAD v6+','Altium Designer','Fusion360 PCB']; var n=0; document.querySelectorAll('input[type=checkbox]').forEach(cb=>{var lbl=cb.labels?.[0]?.textContent.trim()||''; if(labels.indexOf(lbl)>=0){cb.checked=true; cb.dispatchEvent(new Event('change',{bubbles:true})); n++;}}); return n;})()\"}" >/dev/null 2>&1++# Click I'm-not-a-robot via trusted-click on its iframe checkbox+post_act LM358 "clicking I'm not a robot"+RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"expr\":\"(()=>{var f=document.querySelector('iframe[src*=anchor]'); if(!f) return null; var r=f.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+30),y:Math.round(r.y+r.height/2)});})()\"}" 2>/dev/null \+ | python3 -c "import sys,json; r=json.load(sys.stdin).get('result'); print(r if isinstance(r,str) else '')")+CX=$(echo "$RECT" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('x','0'))")+CY=$(echo "$RECT" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('y','0'))")+echo " checkbox click at ($CX,$CY)"+adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESS\",\"type\":\"click\",\"x\":$CX,\"y\":$CY}" >/dev/null 2>&1+sleep 3.5 # give grid time to render++# 2. Get current iframe rect (the bframe that holds the tile grid)+RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"expr\":\"(()=>{var f=document.querySelector('iframe[src*=bframe]'); if(!f) return null; var r=f.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});})()\"}" 2>/dev/null \+ | python3 -c "import sys,json; r=json.load(sys.stdin).get('result'); print(r if isinstance(r,str) else '')")+IX=$(echo "$RECT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('x','0'))")+IY=$(echo "$RECT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('y','0'))")+IW=$(echo "$RECT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('w','0'))")+IH=$(echo "$RECT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('h','0'))")+echo " bframe rect: $IX $IY $IW $IH"++# 3. Click each tile in sequence WITH SLEEP between — checkmark appears between clicks+SIDE_PCT=0.027; HEADER_PCT=0.16; FOOTER_PCT=0.18+GRID_X0=$(python3 -c "print(int($IX + $IW * $SIDE_PCT))")+GRID_Y0=$(python3 -c "print(int($IY + $IH * $HEADER_PCT))")+GRID_W=$(python3 -c "print(int($IW * (1 - 2 * $SIDE_PCT)))")+GRID_H=$(python3 -c "print(int($IH * (1 - $HEADER_PCT - $FOOTER_PCT)))")+TW=$(python3 -c "print($GRID_W / 3)")+TH=$(python3 -c "print($GRID_H / 3)")++TILES=(1 2 8)+for T in "${TILES[@]}"; do+ ROW=$(( (T - 1) / 3 ))+ COL=$(( (T - 1) % 3 ))+ X=$(python3 -c "print(int($GRID_X0 + ($COL + 0.5) * $TW))")+ Y=$(python3 -c "print(int($GRID_Y0 + ($ROW + 0.5) * $TH))")+ post_act LM358 "clicking motorcycle tile $T"+ echo " tile $T at ($X,$Y)"+ adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESS\",\"type\":\"click\",\"x\":$X,\"y\":$Y}" >/dev/null 2>&1+ sleep 1.6 # checkmark animates in ~700ms; gives clear visual gap between tiles+done++# 4. Click Verify+VX=$((IX + IW - 75))+VY=$((IY + IH - 40))+post_act LM358 "clicking Verify"+sleep 0.5+adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESS\",\"type\":\"click\",\"x\":$VX,\"y\":$VY}" >/dev/null 2>&1+sleep 4 # captcha closes, green checkmark renders, token populates++# 5. Click the form's main Submit (download) button — atomic eval+post_act LM358 "captcha solved → submitting download"+adom-desktop browser_eval "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"expr\":\"(()=>{var btn=document.getElementById('SubmitLink')||document.querySelector('button[type=submit]'); if(btn){btn.removeAttribute('disabled'); btn.click(); return 'clicked: '+(btn.id||btn.textContent);} return 'no submit button';})()\"}" >/dev/null 2>&1+sleep 6 # 'Your Ultra Librarian download is being processed' page renders+post_act LM358 "download succeeded ✓"+sleep 2
demo/chip-fetcher-0.1.3-demo-script.mdadded+75@@ -0,0 +1,75 @@+# chip-fetcher 0.1.3 — Feature Tour++**Target duration:** 0:50–1:00+**Recorded:** 2026-05-04+**Final output:** `~/project/recordings/chip-fetcher-0.1.3-tour.webm`+**Wiki target:** `apps/chip-fetcher`+**Hero frame:** `00:04` of scene 1 (populated dashboard)++## Dependencies++- `adom-tts` — `/home/adom/project/adom-tts/target/release/adom-tts`+- `adom-desktop` — pup recording via CDP screencast+- `video-post` — `/home/adom/project/video-post/target/release/video-post`+- `ffmpeg 6+` — container-provided++## Recording config++- **Surface:** pup (`sessionId: chip-fetcher-demo`, separate from the live chip-fetcher session so we don't disturb the user's UL cookie state)+- **Voice:** `en-US-AndrewMultilingualNeural`+- **Frame rate:** 15 fps (with foreground-rebump pattern)+- **Resolution:** native pup window+- **Captions:** rendered in-page via injected `<div>` (pup doesn't support `adom-cli hydrogen caption show`)++## Pre-flight setup++1. chip-fetcher dashboard already serving at port 8780 (verify `curl HTTP 200` first).+2. UL Pro session already authenticated ([email protected] cookie in pup profile).+3. The 31/34 library is populated.+4. Open pup window with `adom-desktop browser_open_window` against the proxy URL.+5. **Foreground-rebump before every action** — pup paint-throttles otherwise.++## Scenes++| # | id | Caption (on-screen) | Narration (TTS, phonetics applied) | Length | Driver actions |+|---|----|---------------------|------------------------------------|--------|----------------|+| 1 | hero | `chip-fetcher 0.1.3` | `This is chip dash fetcher zero point one point three — Adom's automated chip C A D harvester. One command and a chip's full library lands in your project: step file, K I cad symbol and footprint, Altium S C H lib and P C B lib, Fusion three sixty l b r, and datasheets.` | 14s | Land on dashboard. Slow scroll cards from top. |+| 2 | library | `31 fully bundled · 3 stubs · 502 MB` | `The dashboard is your home base. Thirty one chips fully bundled, three stubs awaiting a manufacturer release, every card showing live Mouser stock and price.` | 10s | Hover a few cards, briefly zoom on badges. |+| 3 | credentials | `Credentials vault — masked by default` | `Vendor portal credentials live in a per host vault. Passwords are masked in the U I — click the dots to reveal one if you need to copy it.` | 10s | Open Credentials popover, screenshot, click-to-reveal one cred. |+| 4 | detail | `BME688 — every format, one click` | `Click any chip to see its full file inventory. The B M E six eight eight bundle: K I cad symbol, K I cad footprint, Altium S C H and P C B lib, Fusion three sixty l b r, manufacturer step, and seven data sheet PDFs.` | 14s | Close popover, click BME688 card, panel expands on right. |+| 5 | ul-pro | `UL Pro fast-path — no captcha when logged in` | `New in zero point one point three: the Ultra Librarian Pro logged in flow. Sign in once, then every fetch is captcha free. Seven chips this session, thirty seconds each.` | 12s | Show dashboard with mock activity heartbeat (POST to /api/activity). |++**Total target:** ~60s.++## TTS pronunciation overrides applied++(see `gallia/skills/tts-pronunciation/pronunciations.json` for canonical list)++- `chip-fetcher` → `chip dash fetcher`+- `0.1.3` → `zero point one point three`+- `KiCad` → `K I cad`+- `SchLib` / `PcbLib` → `S C H lib` / `P C B lib`+- `lbr` → `l b r`+- `BME688` → `B M E six eight eight`+- `MPN` / `CAD` / `UI` / `PDF` → spelled with spaces+- `Mouser` → `mouser` (TTS gets this right)+- `Fusion 360` → `Fusion three sixty`++## Reproduction++```bash+cd /home/adom/project/chip-fetcher+bash demo/record.sh # drives the per-scene recording+bash demo/mux.sh # generates TTS, muxes, registers with video-post manifest+video-post storyboard /tmp/cf-demo/manifest.json # review (MANDATORY gate)+# after approval:+bash demo/concat.sh # ffmpeg concat → final.webm + hero.png+adom-wiki asset upload apps/chip-fetcher --asset-type video --file ~/project/recordings/chip-fetcher-0.1.3-tour.webm \+ --caption "chip-fetcher 0.1.3 feature tour — dashboard, masked credentials, detail panel, UL Pro fast-path"+```++## Post-publish cleanup++- `pkill -f "video-post storyboard"`+- Close `chip-fetcher-demo` pup session (`browser_close_window`).+- Restore the user's original `chip-fetcher` pup session as the visible one.
demo/record.shadded+117@@ -0,0 +1,117 @@+#!/bin/bash+# chip-fetcher 0.1.3 demo — per-scene recorder+set -u+SESS="chip-fetcher-demo"+TAB="tab-1"+DEMO=/tmp/cf-demo+mkdir -p "$DEMO"/{raw,clips}++post_act() {+ curl -s -X POST http://127.0.0.1:8780/api/activity \+ -H "Content-Type: application/json" \+ -d "{\"mpn\":\"$1\",\"stage\":\"$2\"}" > /dev/null+}++eval_pup() {+ local expr="$1"+ adom-desktop browser_eval "$(python3 -c "import sys, json; print(json.dumps({'sessionId':'$SESS','tabId':'$TAB','expr':sys.argv[1]}))" "$expr")" >/dev/null 2>&1+}++focus_pup() {+ adom-desktop browser_focus_window "{\"sessionId\":\"$SESS\"}" >/dev/null 2>&1+}++record_scene() {+ local id="$1"; local duration="$2"; shift 2+ echo " scene $id ($duration s): $*"+ focus_pup; sleep 0.5+ post_act "DEMO" "$id"+ # Start nudge loop in background to keep compositor active+ ( for i in $(seq 1 $((duration * 5))); do+ eval_pup 'document.documentElement.style.setProperty("--nudge", Math.random())'+ sleep 0.2+ done ) &+ local NPID=$!+ # Start recording+ local START=$(adom-desktop browser_record_start "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"fps\":15,\"quality\":75}" 2>&1)+ local RID=$(echo "$START" | python3 -c "import sys,json; print(json.load(sys.stdin).get('recordingId',''))")+ # Run scene actions in foreground+ "$@"+ # Stop recording+ local STOP=$(adom-desktop browser_record_stop "{\"sessionId\":\"$SESS\",\"tabId\":\"$TAB\",\"recordingId\":\"$RID\"}" 2>&1)+ kill $NPID 2>/dev/null; wait $NPID 2>/dev/null+ local FILEPATH=$(echo "$STOP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('filePath',''))")+ local FPS=$(echo "$STOP" | python3 -c "import sys,json; print(round(json.load(sys.stdin).get('fpsActual',0),1))")+ local FRAMES=$(echo "$STOP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('frameCount',0))")+ echo " captured $FRAMES frames at $FPS fps -> $FILEPATH"+ # Pull from Windows+ adom-desktop pull_file "{\"filePaths\":[\"$FILEPATH\"],\"saveTo\":\"$DEMO/raw\"}" >/dev/null 2>&1+ local LOCAL=$(basename "$FILEPATH")+ if [[ -f "$DEMO/raw/$LOCAL" ]]; then+ cp "$DEMO/raw/$LOCAL" "$DEMO/clips/$id.webm"+ echo " saved -> $DEMO/clips/$id.webm ($(du -h "$DEMO/clips/$id.webm" | cut -f1))"+ else+ echo " ERROR: pull failed for $LOCAL"+ fi+}++# === Scene 1: hero — populated dashboard ===+scene_hero() {+ # Just slow scroll the cards for the hero+ for i in 0 100 200 300 200 100 0; do+ eval_pup "window.scrollTo({top:$i, behavior:'smooth'})"+ sleep 1.5+ done+}++# === Scene 2: library — card detail ===+scene_library() {+ # Hover/highlight individual cards briefly+ eval_pup "(()=>{var c=Array.from(document.querySelectorAll('.card')).slice(0,4); var i=0; var iv=setInterval(()=>{c.forEach(x=>x.style.outline=''); if(i<c.length){c[i].style.outline='2px solid #3b82f6'; c[i].scrollIntoView({block:'center'});} i++; if(i>c.length){clearInterval(iv);}},1500); window._sceneLib=iv;})()"+ sleep 8+ eval_pup "clearInterval(window._sceneLib); document.querySelectorAll('.card').forEach(c=>c.style.outline='')"+}++# === Scene 3: credentials — masked + click-to-reveal ===+scene_credentials() {+ eval_pup "document.getElementById('btn-creds').click()"+ sleep 3+ # Click a pw-mask to reveal+ eval_pup "(()=>{var pw=document.querySelectorAll('.pw-mask'); if(pw[1]) pw[1].click();})()"+ sleep 3+ # Hide it again+ eval_pup "(()=>{var pw=document.querySelectorAll('.pw-mask'); if(pw[1]) pw[1].click();})()"+ sleep 2+}++# === Scene 4: detail panel — BME688 ===+scene_detail() {+ # Close cred popover+ eval_pup "(()=>{var b=document.getElementById('cred-close'); if(b) b.click();})()"+ sleep 2+ # Click BME688 card+ eval_pup "(()=>{var c=Array.from(document.querySelectorAll('.card')).find(c=>/BME688/.test(c.textContent)); if(c){c.scrollIntoView({block:'center', behavior:'smooth'}); setTimeout(()=>c.click(),500);}})()"+ sleep 8+}++# === Scene 5: UL Pro fast-path — mock activity ===+scene_ul_pro() {+ # Cycle through realistic stages so the dashboard heartbeat shows the flow+ local STAGES=("searching UL Pro" "loading part page" "selecting Altium + Fusion formats" "submitting download request" "downloading from UL Pro" "pulling zip into chip-fetcher" "distributing Altium + Fusion" "done")+ for s in "${STAGES[@]}"; do+ post_act "BQ25798RQMR" "$s"+ sleep 1.4+ done+ curl -s -X POST http://127.0.0.1:8780/api/activity -H "Content-Type: application/json" -d '{}' > /dev/null+ sleep 1+}++# Drive+echo "=== chip-fetcher 0.1.3 demo recording ==="+record_scene hero 12 scene_hero+record_scene library 10 scene_library+record_scene credentials 12 scene_credentials+record_scene detail 12 scene_detail+record_scene ul_pro 14 scene_ul_pro+echo "=== done ==="+ls -la $DEMO/clips/
install.sh+1−1@@ -1,4 +1,4 @@ #!/bin/bash set -euo pipefail DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"-install -m 0755 "$DIR/bin/chip-fetcher" "$BIN/chip-fetcher"; echo "Installed chip-fetcher"+install -m 0755 "$DIR/bin/adom-chip-fetcher" "$BIN/adom-chip-fetcher"; echo "Installed adom-chip-fetcher -> $BIN/adom-chip-fetcher"
selftest.cjsadded+142@@ -0,0 +1,142 @@+// Ralph-loop self-test for the chip-fetcher dashboard. Drives the REAL UI in+// headless Chromium at a phone viewport, exercises the source carat + zoom, and+// asserts + screenshots so regressions are caught here, not on John's phone.+//+// node chip-fetcher/selftest.cjs # full run+// Screenshots → /tmp/selftest/*.png+const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');+const fs = require('fs');++const CHROME = '/home/adom/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome';+const BASE = 'http://127.0.0.1:8786/';+const OUT = '/tmp/selftest';+const MPN = process.argv[2] || 'BMI270';+const PROJ = process.argv[3] || 'Multi-source demo 2 (BMI270)';++const sleep = (ms) => new Promise(r => setTimeout(r, ms));+fs.mkdirSync(OUT, { recursive: true });+const checks = [];+const check = (name, ok, detail = '') => { checks.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };++(async () => {+ const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--disable-gpu'] });+ const page = await browser.newPage();+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true });+ page.on('pageerror', e => console.log('PAGEERROR', e.message));++ const url = `${BASE}?project=${encodeURIComponent(PROJ)}&chip=${encodeURIComponent(MPN)}`;+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });+ await page.waitForSelector('.card', { timeout: 15000 });+ await sleep(1200);+ await page.screenshot({ path: `${OUT}/1-card.png` });++ // The deep-linked chip is present + filtered.+ const cards = await page.$$eval('.card', els => els.map(e => e.getAttribute('data-mpn')));+ check('deep-link filters to the chip', cards.length === 1 && cards[0] === MPN, `cards=${JSON.stringify(cards)}`);++ // Footprint carat: list sources + the canonical (current) FP thumbnail must NOT+ // be the ds2sf one (the bug John found).+ const fpInfo = await page.evaluate(() => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ if (!grp) return { err: 'no footprint group' };+ const chip = grp.querySelector('.src-chip-lab')?.textContent;+ const img = grp.querySelector('.thumb img');+ const opts = [...grp.querySelectorAll('.src-opt')].map(o => o.querySelector('.src-opt-lab').textContent);+ return { chip, imgSrc: img?.src, opts };+ });+ check('FP carat lists multiple sources', (fpInfo.opts || []).length >= 3, `opts=${JSON.stringify(fpInfo.opts)}`);+ check('FP tile defaults to a vendor source (not ds2sf)', /SnapEDA|Ultra|Component/.test(fpInfo.chip || ''), `chip=${fpInfo.chip}`);+ // ds2sf with no pads must NOT appear as a footprint source.+ check('ds2sf not listed as a FP source when it has no footprint', !(fpInfo.opts || []).some(o => /ds2sf/i.test(o)), `opts=${JSON.stringify(fpInfo.opts)}`);++ // "Open in" menu (detail panel) exposes every tool for the chip.+ const tools = await page.$$eval('.openin-row .openbtn', els => els.map(e => e.textContent));+ check('"Open in" lists adom-symbol/footprint/lbr/3D', ['adom-symbol', 'adom-footprint', 'adom-lbr', 'adom-3D'].every(t => tools.includes(t)), `tools=${JSON.stringify(tools)}`);+ check('"Open in" includes chipsmith', tools.includes('chipsmith'));+ check('"Open in" includes ds2sf extraction', tools.includes('ds2sf extraction'));++ // Detail panel is tabbed; Actions is the default (Files must NOT dominate).+ const tabs = await page.evaluate(() => {+ const t = [...document.querySelectorAll('.dtab')].map(x => x.textContent.trim());+ const onTab = document.querySelector('.dtab.on')?.textContent.trim();+ const filesPanelVisible = document.querySelector('.dpanel[data-panel="files"]')?.classList.contains('on');+ return { t, onTab, filesPanelVisible };+ });+ check('detail panel is tabbed (Actions/Details/Files)', tabs.t.some(x => x.startsWith('Actions')) && tabs.t.some(x => x.startsWith('Files')), `tabs=${JSON.stringify(tabs.t)}`);+ check('Actions is the default tab (Files not dominating)', /Actions/.test(tabs.onTab || '') && !tabs.filesPanelVisible, `onTab=${tabs.onTab}`);+ await page.evaluate(() => document.querySelector('.openin-row')?.scrollIntoView());+ await page.screenshot({ path: `${OUT}/4-detail-tabs.png` });++ // The canonical FP thumbnail must actually load (not broken) and not be ds2sf-synth.+ const fpThumb = await page.evaluate(async () => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ const img = grp.querySelector('.thumb img');+ const ok = img.complete && img.naturalWidth > 0;+ let body = '';+ try { body = await (await fetch(img.src)).text(); } catch {}+ return { ok, hasDs2sf: /ds2sf|SYNTHESI/i.test(body) };+ });+ check('canonical FP thumbnail loads (not broken)', fpThumb.ok);+ check('canonical FP thumbnail is NOT ds2sf-synthesized', !fpThumb.hasDs2sf);++ // Switch the FP source via the carat → label sticks + tile re-renders.+ await page.evaluate(() => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ grp.querySelector('.src-chip').click();+ });+ await sleep(300);+ const switched = await page.evaluate(() => {+ const pop = [...document.querySelectorAll('.src-popover')].find(p => p.offsetParent !== null);+ const ul = [...pop.querySelectorAll('.src-opt')].find(o => /Ultra/.test(o.textContent));+ ul.click();+ const grp = ul.closest('.thumb-src-group');+ return grp.querySelector('.src-chip-lab').textContent;+ });+ await sleep(400);+ check('picking a source updates the carat label (sticks)', /Ultra/.test(switched), `label=${switched}`);+ await page.screenshot({ path: `${OUT}/2-source-switched.png` });++ // Zoom: tap the zoom button → full-screen overlay with a visible X.+ await page.evaluate(() => { document.querySelector('.card .zoom-btn').click(); });+ await sleep(700);+ const zoom = await page.evaluate(() => {+ const bd = document.querySelector('.card-zoom-backdrop');+ const x = document.querySelector('.card-zoom-x');+ const wrap = document.querySelector('.card-zoomed-wrapper');+ const fills = wrap ? wrap.getBoundingClientRect().width / window.innerWidth : 0;+ return { hasBackdrop: !!bd, fullscreen: bd?.classList.contains('fullscreen'), hasX: !!x && getComputedStyle(x).display !== 'none', widthFrac: fills };+ });+ check('zoom opens an overlay', zoom.hasBackdrop);+ check('zoom is full-screen on phone', zoom.fullscreen && zoom.widthFrac > 0.85, `widthFrac=${zoom.widthFrac.toFixed(2)}`);+ check('zoom has a visible X close button', zoom.hasX);+ // The zoom focus view exposes Open-in + Send-to (reachable on a phone).+ const zoomActions = await page.evaluate(() => ({+ open: [...document.querySelectorAll('.card-zoomed-wrapper .openin-row .openbtn')].map(b => b.textContent),+ send: [...document.querySelectorAll('.card-zoomed-wrapper .sendto-row .sendbtn')].map(b => b.textContent),+ }));+ check('zoom view has Open-in actions', zoomActions.open.includes('adom-symbol') && zoomActions.open.includes('chipsmith'), `open=${JSON.stringify(zoomActions.open)}`);+ check('zoom view has Send-to actions', zoomActions.send.length === 4, `send=${JSON.stringify(zoomActions.send)}`);+ await page.screenshot({ path: `${OUT}/3-zoom.png` });++ // Tapping "Open in adom-symbol" from the zoom actually opens a NEW TAB+ // (window.open) — the phone-correct method.+ const before = (await browser.pages()).length;+ await page.evaluate(() => [...document.querySelectorAll('.card-zoomed-wrapper .openin-row .openbtn')].find(b => b.textContent === 'adom-symbol').click());+ await sleep(2500);+ const after = (await browser.pages()).length;+ check('opening adom-symbol spawns a new browser tab (window.open)', after > before, `pages ${before}→${after}`);+ const toast = await page.evaluate(() => document.getElementById('cf-toast')?.textContent || '');+ check('user is told the open method', /new browser tab|window\.open|Hydrogen/i.test(toast), `toast="${toast.trim()}"`);++ // X closes it.+ await page.evaluate(() => document.querySelector('.card-zoom-x').click());+ await sleep(500);+ const closed = await page.$('.card-zoom-backdrop');+ check('X closes the zoom (back to index)', !closed);++ await browser.close();+ const fails = checks.filter(c => !c.ok);+ console.log(`\n${checks.length - fails.length}/${checks.length} checks passed.`);+ fs.writeFileSync(`${OUT}/result.json`, JSON.stringify(checks, null, 2));+ process.exit(fails.length ? 1 : 0);+})().catch(e => { console.error('SELFTEST ERROR', e); process.exit(2); });
skills/CONTRIBUTING.mdadded+68@@ -0,0 +1,68 @@+# Contributing a source-site skill++You figured out how to navigate a vendor's CAD-download flow — maybe a site+chip-fetcher didn't cover, maybe a new way past a block (the day DigiKey blocked+pup, someone chatted with its assistant and found a path; that knowledge belongs+here). Here's how to get it into the pack so everyone's AI benefits.++## Write the skill++Copy `sites/snapeda/SKILL.md` as a template and fill in the five sections (Reach,+Gate, Download flow, Catch, When it breaks). Keep it to **one site per skill** and+give it real trigger words (the vendor name, "blocked", "captcha", the file types)+so the AI auto-surfaces it at the right moment.++## Two ways to land it — the owner picks per contribution++The pack supports two contribution shapes. Which one is allowed for a given skill+is the **maintainer's call**, made deliberately, trading freshness against trust:++### A. Pull request INTO the pack (vetted — the default)++Open a PR adding `sites/<vendor>/SKILL.md` to this repo. The maintainer reads the+navigation steps, sanity-checks them, and merges. The skill now lives in the+chip-fetcher repo and ships with the pack.++- **Pro:** every step is reviewed before it reaches anyone's AI — the safe default.+- **Con:** updates require another PR; the maintainer is in the loop each time.+- **Use when:** the contributor is new/unknown, or the skill does anything+ sensitive (credential handling, evading a block, executing fetched content).++### B. Linked external skill (trusted — opt-in)++Publish your skill as its **own wiki repo** (you own it, you `adompkg publish`+updates whenever you like). Then PR a one-line **link** into this pack's manifest+instead of the skill body. The pack references your repo; your repo stays yours.++- **Pro:** you keep ownership and can fix your site's flow the hour it changes,+ no maintainer round-trip — exactly what a fast-moving vendor site needs.+- **Con:** the pack now trusts a repo it doesn't control; a later edit isn't+ re-reviewed. So this is **trusted-contributor only**.+- **Use when:** the maintainer knows the contributor and is comfortable letting+ their repo update live.++> The link itself still arrives by PR, so the maintainer always makes the+> conscious decision to trust a given external repo — but once linked, that repo+> self-updates. Default to (A); grant (B) to people you trust. Revoking is just+> dropping the link.++## Security & vetting++Site skills tell an AI to drive a real browser on the user's machine, sometimes+past anti-bot defenses. Treat every contribution as code review:++- No skill should exfiltrate credentials or paste them anywhere but the vendor's+ own login. Creds come from `chip-fetcher creds`, never inline.+- "Getting past a block" means *navigating the site as a human would* (right+ profile, human pacing, the path their own assistant suggested) — **not**+ defeating security controls or solving CAPTCHAs programmatically.+- A linked external skill (path B) inherits the trust you grant its repo. When in+ doubt, require path A.++## Status (for Colby / the wiki)++Path A (PR into the pack) works on the wiki today — it's a normal pull request.+Path B (a pack sub-skill that *links* an externally-owned wiki repo, which then+self-updates) is the piece that needs platform support: a manifest link type the+pack resolves at install time, plus a way for the maintainer to mark a given link+trusted. Tracked in the chip-fetcher Colby feedback.
skills/SKILL.mdadded+84@@ -0,0 +1,84 @@+---+name: chip-fetcher-sites+description: >-+ The chip-fetcher SOURCE-SITE skill pack — one installable skill per vendor+ website that chip-fetcher crawls (SnapEDA, Mouser, DigiKey, Ultra Librarian,+ Arrow, manufacturer sites…). Each sub-skill teaches the AI how to navigate+ THAT site's CAD-download flow through pup: the URL patterns, the login/gate,+ the download button dance, and — crucially — how to get unstuck when a site+ changes its layout or blocks automation. Third parties extend the pack by+ contributing a skill for a site they've figured out. Read this before driving+ chip-fetcher against a vendor site, or when a fetch gets stuck on a site whose+ layout or anti-bot defenses changed. Trigger words: chip-fetcher site, source+ site, vendor navigation, snapeda navigation, digikey blocked, mouser cad,+ ultralibrarian, site skill, crawl site, fetch stuck, anti-bot, captcha,+ download flow, add a site skill, contribute site skill.+---++# chip-fetcher source-site skill pack++chip-fetcher is an **open-source Page** (source public + `adompkg install`-able)+that *also carries a skill pack in the same repo*. The app is the crawler; this+pack is the **per-site navigation knowledge** the AI uses to drive it. They ship+together so the code and the know-how version in lockstep — but the know-how is+the part the community can extend without touching Rust.++## Why per-site skills++Every vendor site has its own CAD-download choreography, and they change it+without notice. SnapEDA gates behind a login; DigiKey periodically tightens+anti-bot and **blocks pup outright**; Mouser buries the 3D model behind a tab;+manufacturer sites (TI, ST, NXP, Nordic…) each have a different "CAD / Symbols &+Footprints" path. Hard-coding all of that in the binary means a site change is a+release. Capturing it as **a skill per site** means:++- the AI reads the current site's skill before navigating, so it knows the exact+ flow instead of guessing;+- when a site changes (or blocks us), **anyone** who figures out the new flow can+ fix it by editing one skill — no fork, no Rust, no release;+- the fix is discoverable: it's published to the wiki and the next person's AI+ picks it up.++The canonical sourcing ladder (Mfr → SnapEDA → Mouser → DigiKey → Arrow → CSE/UL+→ LCSC) still lives in the binary (`chip-fetcher sources`); this pack is the+**how-to-drive-each-rung** layer on top of it.++## Layout++```+skills/+├── SKILL.md # this file — the pack entry point + ladder+├── CONTRIBUTING.md # how 3rd parties add / own a site skill+└── sites/+ ├── snapeda/SKILL.md # the canonical reference flow (login + download)+ ├── digikey/SKILL.md # incl. the "pup is blocked" recovery playbook+ ├── mouser/SKILL.md+ ├── ultralibrarian/SKILL.md+ └── manufacturer/SKILL.md# generic manufacturer-site approach (TI/ST/NXP/Nordic…)+```++Each `sites/<vendor>/SKILL.md` is a complete, installable skill with its own+trigger words, so the AI auto-surfaces the right one when it's about to touch+that vendor — or when a fetch against it gets stuck.++## What a site skill must contain++1. **Reach** — the canonical product-search / product-page URL pattern for an MPN.+2. **Gate** — login / region / cookie wall, and how the persistent pup profile+ gets past it (which profile, what creds key in `chip-fetcher creds`).+3. **Download flow** — the exact button/tab sequence to the CAD bundle, with the+ selectors or visual cues that identify each step.+4. **Catch** — what the downloaded artifact is (zip? IntLib? per-file?), where it+ lands, and how chip-fetcher pulls it off the desktop.+5. **When it breaks** — the failure signatures (block page, captcha, moved button)+ and the recovery playbook. This is the highest-value section — it's why the+ pack exists.++See `sites/snapeda/SKILL.md` for the template filled in.++## Adding a site++Found a site chip-fetcher doesn't know, or a new way past a block? Write a skill+for it and contribute it — `CONTRIBUTING.md` covers the two paths (PR it into the+pack, or own it in your own wiki repo and link it in). Either way your AI, and+everyone else's, gets smarter the next time that site comes up.
skills/sites/digikey/SKILL.mdadded+63@@ -0,0 +1,63 @@+---+name: chip-fetcher-site-digikey+description: >-+ How chip-fetcher navigates DigiKey for a chip's CAD models — and the recovery+ playbook for when DigiKey BLOCKS pup automation (its anti-bot trips+ periodically and serves a challenge/block page instead of the product page).+ Read before a DigiKey fetch, and especially when a DigiKey fetch returns a+ block/challenge instead of the part. Trigger words: digikey, digikey blocked,+ digikey captcha, digikey anti-bot, digikey challenge page, digikey cad,+ digikey step, digikey 3d model, digikey access denied, digikey verify human.+---++# Site: DigiKey++DigiKey carries manufacturer CAD (ECAD model via SnapEDA/SamacSys, 3D STEP) on the+product page. It's a useful rung — **when it lets us in**. DigiKey runs aggressive+bot detection that changes without notice, so the "when it breaks" section here is+the main event.++## Reach+- Search: `https://www.digikey.com/en/products/result?keywords=<MPN>`+- Or product page directly once the DigiKey part number is known.++## Gate+- No login needed for CAD download, but the page is region/cookie-gated and+ bot-screened. Use the persistent pup profile so cookies + a warmed session+ carry over.++## Download flow+1. On the product page, scroll to **Documents & Media** / **CAD Models**.+2. The ECAD model is a SnapEDA/SamacSys embed → may redirect to a partner login;+ the 3D STEP is usually a direct link.+3. Download lands as a `.zip` or `.step` in Downloads; chip-fetcher pulls + unpacks.++## Catch+- Same as the other rungs: pull off the desktop, normalize into `library/<MPN>/`.++## When it breaks — DigiKey blocks pup++**Signature:** instead of the product page you get an "Access Denied",+"verify you are human", a Cloudflare/Akamai interstitial, or an endless spinner.+The page title and a screenshot will show the challenge, not the part.++**Do NOT** try to defeat the challenge programmatically (no CAPTCHA solving, no+header spoofing to look like a different bot). That's the wrong kind of fix and+it's explicitly out of scope (see `../../CONTRIBUTING.md` → Security).++**The right plays, in order:**+1. **Use a warm human profile.** Drive DigiKey in a pup profile the user actually+ browses with — real cookies, real history. A cold automation profile trips the+ screen far more often. Pace actions like a human (no instant clicks).+2. **Ask DigiKey's own assistant.** DigiKey has an on-site chat/AI. Navigating *to+ the model* via the path it hands you often avoids the screened URL entirely.+ (This is exactly how a 3rd party first got past a fresh block — and then+ wrote it up here.)+3. **Skip the rung.** DigiKey is mid-ladder, not the only source. If it's blocking,+ fall to Arrow / CSE-UL rather than fighting it — the part's model is usually+ reachable elsewhere.+4. **Capture what worked and publish it.** If you find a current path through,+ update this section and ship it (`../../CONTRIBUTING.md`). The block changes+ monthly; the freshest skill wins. A fast-moving vendor like this is the textbook+ case for the **linked-external-skill** path so the owner can update it the hour+ it changes.
skills/sites/manufacturer/SKILL.mdadded+47@@ -0,0 +1,47 @@+---+name: chip-fetcher-site-manufacturer+description: >-+ The generic approach for pulling CAD straight from a chip MAKER's own site (TI,+ ST, NXP, Nordic, Microchip, Analog…) — the top rung of the ladder, where the+ STEP is richest and most authoritative. Read before a manufacturer-direct+ fetch, or to add a maker-specific sub-flow. Trigger words: manufacturer cad,+ ti cad download, st cad, nxp cad, nordic cad, microchip cad, analog devices+ cad, manufacturer step, product page cad, maker site.+---++# Site: Manufacturer-direct (generic)++The manufacturer's own product page is the **top rung**: the STEP is the real one,+the symbol/footprint match the datasheet exactly, and there's no third-party+re-draw. Always try here first.++## Reach+- `chip-fetcher sources` + `sourcing.rs` map an MPN prefix → manufacturer + a+ canonical product-search URL. Open that, land on the exact-MPN product page.+- Fallback when the prefix is unknown: a `google.com/search?q=<MPN> CAD STEP site:<maker>.com`+ resolves to the real product page (don't template a `<maker>.com/product/<MPN>`+ guess — those 404 for parts the maker renamed).++## Gate+- Most makers offer CAD without login; some (NXP, ST) gate STEP behind a free+ account or an export-control click-through. Use the persistent profile; creds+ per-maker via `chip-fetcher creds`.++## Download flow+- Find the **CAD / Symbols, Footprints & 3D Models / Design Resources** section on+ the product page. The model is often a partner embed (SnapEDA / Ultra Librarian /+ SamacSys) — if so, hand off to that site's skill. Native STEP downloads land as+ `.step` / `.zip`.++## Catch+- Pull + normalize into `library/<MPN>/`. Manufacturer STEP becomes the canonical+ `<MPN>.step`.++## When it breaks+- **Export-control / login wall** → satisfy it with the per-maker creds, once.+- **Embed instead of native CAD** → follow the embed to the partner site and use+ that site's skill.+- **Per-maker quirks** (TI's "ULTRA Librarian" button, ST's MyST gate, Nordic's+ DeviceTree bundles) deserve their own `sites/<maker>/SKILL.md` — add one when a+ maker's flow is gnarly enough to be worth capturing. `sources/ti.rs` is the code+ counterpart for TI.
skills/sites/mouser/SKILL.mdadded+30@@ -0,0 +1,30 @@+---+name: chip-fetcher-site-mouser+description: >-+ How chip-fetcher pulls CAD + stock/lifecycle from Mouser. Mouser is also the+ authoritative stock/price/lifecycle source (stock.json). Read before a Mouser+ fetch or stock check. Trigger words: mouser, mouser cad, mouser step, mouser+ stock, mouser lifecycle, mouser 3d model, mouser blocked.+---++# Site: Mouser++Mid-ladder for CAD, but the **canonical source for stock + price + lifecycle**+(NRND/EOL) that lands in `stock.json`.++## Reach+- Search: `https://www.mouser.com/c/?q=<MPN>` → product page.++## Gate+- CAD download and the API both want a session/key. Stock/price come via the+ Mouser API (key in `chip-fetcher creds`, used by the inventory path), not pup.++## Download flow+- Product page → **CAD / ECAD Model** (a SnapEDA/SamacSys embed → follow that+ site's skill) and the **3D Model** link. Stock/lifecycle read from the API, not+ the page.++## Catch / When it breaks+- Normalize into `library/<MPN>/`; lifecycle/stock → `stock.json`.+- Embed → hand to the partner site's skill. Block → warm profile, then skip the+ rung (see digikey skill's playbook — same pattern).
skills/sites/snapeda/SKILL.mdadded+51@@ -0,0 +1,51 @@+---+name: chip-fetcher-site-snapeda+description: >-+ How chip-fetcher navigates SnapEDA (SnapMagic) to download a chip's CAD bundle+ — symbol, footprint, 3D STEP, and the Fusion/Eagle .lbr + Altium IntLib —+ through a logged-in pup Chrome profile. The canonical template for a+ source-site skill. Read before driving a SnapEDA fetch, or when a SnapEDA fetch+ stalls at the login wall or the download button. Trigger words: snapeda,+ snapmagic, snapeda login, snapeda download, snapeda blocked, snapeda step,+ snapeda symbol footprint, snapeda lbr, snapeda intlib.+---++# Site: SnapEDA (SnapMagic)++SnapEDA is the highest-yield non-manufacturer rung: it has symbol + footprint ++3D + Eagle/Fusion `.lbr` + Altium for most mainstream parts, behind a free login.++## Reach+- Search: `https://www.snapeda.com/search/?q=<MPN>&SearchType=parts`+- Resolve to the part page (first exact-MPN result). The part page URL looks like+ `https://www.snapeda.com/parts/<MPN>/<Manufacturer>/view-part/`.++## Gate+- Requires a logged-in account to download. Use the persistent pup profile+ configured for SnapEDA; credentials come from `chip-fetcher creds` (key+ `snapeda`), entered **only** into SnapEDA's own login form.+- The session cookie persists in the profile for ~weeks; re-login only when the+ download button bounces back to a login modal.++## Download flow+1. On the part page, click **Download** (top-right). A format panel opens.+2. Pick the targets chip-fetcher wants: **Symbol and Footprint** (KiCad), **3D+ Model** (STEP), and the **Eagle/Fusion** library; Altium if offered.+3. SnapEDA emails-gates some downloads — the logged-in session usually skips that;+ if a "check your email" interstitial appears, the profile isn't logged in.+4. The download lands as a `.zip` in the desktop's Downloads.++## Catch+- chip-fetcher pulls the zip off the desktop (`adom-desktop pull_file`), unpacks+ into `library/<MPN>/`, and normalizes filenames (`<MPN>.step`, `.kicad_sym`,+ `.kicad_mod`, `-fusion.lbr`, `.IntLib`).++## When it breaks+- **Bounced to a login modal on Download** → the profile lost its session.+ Re-login with the `snapeda` creds, then retry. Don't paste creds anywhere but+ the SnapEDA login form.+- **"Request this design" instead of Download** → SnapEDA doesn't have this part's+ model yet. Fall to the next ladder rung; don't spin.+- **Moved Download button / new format panel** → SnapEDA reskinned. Screenshot the+ part page, find the new control by its label ("Download", format names), update+ the flow above, and publish the fix (see `../../CONTRIBUTING.md`).
skills/sites/ti/SKILL.mdadded+19@@ -0,0 +1,19 @@+---+name: chip-fetcher-site-ti+description: >-+ How chip-fetcher pulls CAD from Texas Instruments (ti.com) — the "CAD/Symbols,+ Footprints & 3D Models" widget on a product page (an Ultra Librarian embed) and+ the native STEP. Read before a TI-direct fetch. Trigger words: ti, texas+ instruments, ti.com, ti cad, ti step, ti symbol footprint, ti ultra librarian,+ ti.com blocked.+---+# Site: Texas Instruments (ti.com)+Top-rung manufacturer source — TI's STEP is authoritative and its ECAD is an Ultra Librarian embed.+## Reach+- `https://www.ti.com/product/<PARTFAMILY>` → the specific orderable (e.g. nRF… no; TI parts like TPS62840). Or `google.com/search?q=<MPN> ti.com cad`.+## Gate+- No login for STEP; the "CAD/Symbols, Footprints & 3D Models" section hands off to **Ultra Librarian** (free login) for the ECAD model → use the `ultralibrarian` site skill from there.+## Download flow+- Product page → **Design & development → CAD/CAE symbols** → STEP downloads directly; ECAD opens the UL flow.+## Catch / When it breaks+- Normalize into `library/<MPN>/`. If the UL embed changes, see the `ultralibrarian` skill. TI occasionally A/B-tests the product page layout — find the CAD section by its heading, not a fixed selector, and publish the fix (`../../CONTRIBUTING.md`).
src/cli.rs+455−219@@ -7,7 +7,7 @@ use crate::{hints, import, inventory, library, pup, serve, sources, sourcing, th const SKILL_MD: &str = include_str!("../SKILL.md"); // Playbooks — deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read.-// Embedded at compile time so `chip-fetcher install` deploys them alongside SKILL.md without+// Embedded at compile time so `adom-chip-fetcher install` deploys them alongside SKILL.md without // needing the source repo on the user's container. const PLAYBOOKS: &[(&str, &str)] = &[ ("sourcing-ladder.md", include_str!("../playbooks/sourcing-ladder.md")),@@ -21,11 +21,11 @@ const PLAYBOOKS: &[(&str, &str)] = &[ ("handoff-and-watching.md", include_str!("../playbooks/handoff-and-watching.md")), ]; -const SESSION: &str = "chip-fetcher";-const PROFILE: &str = "chip-fetcher";+const SESSION: &str = "adom-chip-fetcher";+const PROFILE: &str = "adom-chip-fetcher"; #[derive(Parser)]-#[command(name = "chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")]+#[command(name = "adom-chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")] struct Cli { #[command(subcommand)] cmd: Cmd,@@ -33,6 +33,49 @@ struct Cli { #[derive(Subcommand)] enum Cmd {+ /// PLAN a batch: show the user the full queue up front + seed planned-state+ /// dashboard cards, BEFORE any scraping. This is the required first step —+ /// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN+ /// that isn't in the active plan. That's the guardrail that stops freelancing.+ Plan {+ /// The MPNs to source, in order.+ mpns: Vec<String>,+ /// Name the plan (defaults to "batch"). Becomes the active project.+ #[arg(long, default_value = "batch")]+ name: String,+ },+ /// Scan the native browser's SAVED LOGINS across every profile (Chrome ++ /// Edge), map them to the sourcing ladder, and report which CAD/distributor+ /// vendors you're signed in to + the best profile. adom-chip-fetcher uses this to+ /// self-drive: it auto-switches to the vendor-authed profile while sourcing.+ Logins,+ /// Fetch a part's full CAD bundle (symbol+footprint+STEP) from Component+ /// Search Engine via XHR + basic-auth — no browser download, no prompts, no+ /// questions. ST/TI/NXP/etc. route their EDA models here. First-class ++ /// permanent: works for any user with a CSE login in the credential vault.+ Cse {+ mpn: String,+ /// Manufacturer name for CSE's `mna` param (default: inferred from MPN).+ #[arg(long)]+ mfr: Option<String>,+ },+ /// Set the human "what is this chip" description shown on the card + zoom+ /// view (writes info.json.description). Every sourced chip should have one.+ Describe {+ mpn: String,+ /// The description text (one or two sentences: what the part IS).+ text: String,+ },+ /// Enrich a chip from the Adom parts tools (adom-parts-search → Mouser ++ /// DigiKey + JLCPCB): writes the description, manufacturer, stock.json+ /// (stock + pricing + lifecycle) and lists orderable package variants.+ /// Keys are baked into those tools' own services — NO API key needed.+ Enrich { mpn: String },+ /// Run the MOST authoritative source: extract symbol + footprint from the+ /// manufacturer DATASHEET via adom-ds2sf. GUARANTEES the datasheet is real+ /// (multi-page + pinout) first — refuses a 1-page denial page — then runs+ /// the extraction and renders the ds2sf variant thumbnails.+ Ds2sf { mpn: String }, /// Fetch one MPN from the best available source Fetch { mpn: String,@@ -72,7 +115,7 @@ enum Cmd { source_url: String, }, /// Pull a file from the user's desktop and import it into the library.- /// e.g. `chip-fetcher pull "C:\\Users\\john\\Downloads\\ul_TPS62840YBGR.zip" --mpn TPS62840YBGR`+ /// e.g. `adom-chip-fetcher pull "C:\\Users\\john\\Downloads\\ul_TPS62840YBGR.zip" --mpn TPS62840YBGR` Pull { /// Windows/macOS path on the user's desktop remote_path: String,@@ -92,7 +135,7 @@ enum Cmd { #[arg(long, default_value = "")] source_url: String, },- /// Deploy SKILL.md to ~/.claude/skills/chip-fetcher/ so Claude knows how to use this tool.+ /// Deploy SKILL.md to ~/.claude/skills/adom-chip-fetcher/ so Claude knows how to use this tool. /// Run after `curl ... | install` to finish setup. Install, /// Run the viewer HTTP server. Open in any browser at http://localhost:<port>.@@ -113,7 +156,7 @@ enum Cmd { stage: String, }, /// Verify the rails before a fetch — dashboard reachable, heartbeat- /// fresh, pup session 'chip-fetcher' open. Fails with HINT lines so the+ /// fresh, pup session 'adom-chip-fetcher' open. Fails with HINT lines so the /// next step is mechanical, not remembered. Selfcheck, /// Print a HINT line about what to do next, based on current state.@@ -121,12 +164,12 @@ enum Cmd { Hint, /// Print the canonical sourcing-ladder priority order (Mfr → SnapMagic → /// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT —- /// avoid if possible. AI: read this with `chip-fetcher sources` BEFORE+ /// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE /// falling back from one source to the next; do NOT skip ahead to LCSC. Sources, /// Hard gate: scan every chip in library/ and refuse to declare success /// while ANY card is incomplete (stub-only or missing core CAD). Returns- /// non-zero exit when work remains. AI: ALWAYS run `chip-fetcher gate`+ /// non-zero exit when work remains. AI: ALWAYS run `adom-chip-fetcher gate` /// before reporting "done" on a batch — it prints exactly which files /// each card is missing, so claiming done is mechanical, not remembered. Gate {@@ -140,7 +183,7 @@ enum Cmd { }, /// Detect manufacturer from MPN and open their canonical product / /// search page in pup. AI: run this BEFORE any third-party source.- /// The mfr-first rule is enforced in code — `chip-fetcher mfr-probe+ /// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe /// <MPN>` either opens the right URL or tells you we can't infer the /// manufacturer (in which case the AI must do its own probe). Auto- /// records the manufacturer-attempt on info.json.fetched_via_chain.@@ -170,8 +213,8 @@ enum Cmd { /// Chip MPN — directory must already exist at library/<MPN>/. mpn: String, },- /// Drive the chip-fetcher pup scrape session. Every action auto-- /// heartbeats the dashboard, always uses `profile: "chip-fetcher"`+ /// Drive the adom-chip-fetcher pup scrape session. Every action auto-+ /// heartbeats the dashboard, always uses `profile: "adom-chip-fetcher"` /// (NEVER a different profile — that loses vendor login cookies), /// and logs every step to the fetched_via_chain. The AI calls these /// instead of raw `adom-desktop browser_*` calls so the dashboard@@ -181,8 +224,8 @@ enum Cmd { op: ScrapeOp, }, /// Manage stored vendor credentials. Saved to- /// ~/.config/chip-fetcher/credentials.json on the user's container.- /// Survives across sessions — the user enters creds ONCE, chip-fetcher+ /// ~/.config/adom-chip-fetcher/credentials.json on the user's container.+ /// Survives across sessions — the user enters creds ONCE, adom-chip-fetcher /// can re-login automatically when cookies expire. Creds { #[command(subcommand)]@@ -242,14 +285,14 @@ enum Cmd { #[arg(long, default_value = "manufacturer")] source: String, /// Pup session id (default: `cf-pdf-grab` — kept separate from- /// the dashboard `chip-fetcher` and the scrape `chip-fetcher-scrape`+ /// the dashboard `adom-chip-fetcher` and the scrape `adom-chip-fetcher-scrape` /// sessions so this can run alongside an existing fetch). #[arg(long, default_value = "cf-pdf-grab")] session: String, }, /// Re-fetch a specific library file from a specific source. Drives the /// vendor's product/CAD page in pup, watches ~/Downloads for the- /// resulting bundle, and imports it. Used by `chip-fetcher validate`+ /// resulting bundle, and imports it. Used by `adom-chip-fetcher validate` /// when concur returns `refetch_library_source` — the loop calls this /// as a subprocess so a divergent kicad_sym can be replaced with one /// from a different upstream without a human in the loop.@@ -291,7 +334,7 @@ enum Cmd { #[arg(long)] force: bool, },- /// Manage projects — scope chip-fetcher work to a named project so the+ /// Manage projects — scope adom-chip-fetcher work to a named project so the /// dashboard shows only the chips you care about right now. Project { #[command(subcommand)]@@ -328,13 +371,13 @@ enum ProjectOp { enum DashboardOp { /// Make sure the dashboard is up and visible. Idempotent. Pup by default. Ensure {- /// Re-open even if a chip-fetcher pup window is already detected.+ /// Re-open even if a adom-chip-fetcher pup window is already detected. #[arg(long)] force: bool, }, /// Set the surface preference (pup | webview). Saved at- /// ~/.config/chip-fetcher/dashboard.json. Pup is the default since- /// chip-fetcher needs pup anyway to drive vendor sites.+ /// ~/.config/adom-chip-fetcher/dashboard.json. Pup is the default since+ /// adom-chip-fetcher needs pup anyway to drive vendor sites. SetSurface { /// Either "pup" or "webview" surface: String,@@ -344,7 +387,7 @@ enum DashboardOp { } /// Every pup action the AI drives during a fetch. ALWAYS uses-/// `profile: "chip-fetcher"` — hardcoded, non-overridable. This+/// `profile: "adom-chip-fetcher"` — hardcoded, non-overridable. This /// preserves vendor login cookies across sessions and ensures the /// dashboard heartbeat fires on every action. #[derive(Subcommand)]@@ -367,7 +410,7 @@ enum ScrapeOp { Screenshot { mpn: String, },- /// Open the scrape window (or re-open if dead). Always profile=chip-fetcher.+ /// Open the scrape window (or re-open if dead). Always profile=adom-chip-fetcher. Open { mpn: String, url: String,@@ -378,7 +421,7 @@ enum ScrapeOp { enum CredsOp { /// List stored credentials (shows host + username, masks password). List,- /// Save a credential. Persists to ~/.config/chip-fetcher/credentials.json.+ /// Save a credential. Persists to ~/.config/adom-chip-fetcher/credentials.json. Save { /// Vendor host slug (snapeda, ultralib, componentsearch, ti, st, etc.) host: String,@@ -392,12 +435,19 @@ enum CredsOp { pub fn run() -> Result<()> { let cli = Cli::parse(); match cli.cmd {+ Cmd::Logins => cmd_logins(),+ Cmd::Cse { mpn, mfr } => cmd_cse(&mpn, mfr.as_deref()),+ Cmd::Describe { mpn, text } => cmd_describe(&mpn, &text),+ Cmd::Enrich { mpn } => cmd_enrich(&mpn),+ Cmd::Ds2sf { mpn } => cmd_ds2sf(&mpn),+ Cmd::Plan { mpns, name } => cmd_plan(&mpns, &name), Cmd::Fetch { mpn, project } => { if let Some(ref proj) = project { let _ = crate::projects::add_mpn(proj, &mpn); let _ = crate::projects::set_active(Some(proj)); println!("project: {proj} (added {mpn})"); }+ require_planned(&mpn)?; fetch(&mpn) } Cmd::Login { source } => login(&source),@@ -415,7 +465,7 @@ pub fn run() -> Result<()> { Cmd::Hint => hint_cmd(), Cmd::Sources => sources_cmd(), Cmd::Gate { strict, mpn } => gate_cmd(strict, mpn.as_deref()),- Cmd::MfrProbe { mpn, no_open } => mfr_probe_cmd(&mpn, no_open),+ Cmd::MfrProbe { mpn, no_open } => { require_planned(&mpn)?; mfr_probe_cmd(&mpn, no_open) } Cmd::NextSource { mpn } => next_source_cmd(&mpn), Cmd::Validate { mpn } => validate_cmd(&mpn), Cmd::Refetch { mpn, format, source, url_only, timeout } =>@@ -432,6 +482,268 @@ pub fn run() -> Result<()> { } } +/// First-class CSE source: fetch the full CAD bundle via XHR + basic-auth (no+/// download, no prompt, no questions) and import it. The permanent version of+/// what unblocked the VL53Lx family.+fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> {+ require_planned(mpn)?;+ let cred = crate::creds::load()?.into_iter()+ .find(|c| c.host.contains("componentsearchengine"))+ .ok_or_else(|| anyhow::anyhow!(+ "no Component Search Engine credential in the vault.\n \+ HINT: sign in to componentsearchengine.com once in your browser, or \+ `adom-chip-fetcher creds save componentsearchengine <user> <pass>`"+ ))?;+ let mfr = mfr.map(String::from)+ .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.manufacturer.to_string()))+ .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass --mfr <name>"))?;+ // Keep the dashboard "working on" bar LIVE for the whole browser fetch —+ // cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would+ // go stale mid-fetch. The ticker re-pulses every 6s until it's stopped.+ let ticker = hints::HeartbeatTicker::start(mpn, &format!("CSE: fetching CAD via your browser ({mfr})"));+ let bytes = pup::cse_fetch_zip(mpn, &mfr, &cred.username, &cred.password)?;+ ticker.stop();+ let zip = std::env::temp_dir().join(format!("cse_{mpn}.zip"));+ std::fs::write(&zip, &bytes)?;+ // Keep the ORIGINAL downloaded archive in the library so the user can+ // inspect the exact bytes we pulled off the internet (folder structure, all+ // EDA formats, the 3D, etc.) — not just the files we extracted from it.+ let orig = library::root().join(mpn).join(format!("{mpn}.cse-bundle.zip"));+ let _ = std::fs::create_dir_all(orig.parent().unwrap());+ let _ = std::fs::write(&orig, &bytes);+ println!("OK: CSE zip {} KB → saved original + importing symbol+footprint+STEP", bytes.len() / 1024);+ let _ = hints::heartbeat(mpn, "importing CSE bundle");+ import_cmd(&zip, Some(mpn), false, "cse", "manufacturer", "")?;+ let _ = hints::heartbeat(mpn, "imported");+ // First-class metadata: pull description + stock + pricing from the Adom+ // parts tools (keys baked in — no API key). Best-effort; never fails the run.+ auto_enrich(mpn);+ // The DATASHEET is the most authoritative source — ALWAYS run ds2sf so every+ // fetched chip gets the independent datasheet extraction. Best-effort: a+ // ds2sf failure doesn't fail the CAD fetch, but the gate WILL flag it (so it+ // can never be silently skipped). Set CHIP_FETCHER_SKIP_DS2SF=1 to opt out.+ if std::env::var("CHIP_FETCHER_SKIP_DS2SF").is_err() {+ if let Err(e) = cmd_ds2sf(mpn) {+ println!("WARN: ds2sf did not complete for {mpn}: {e}");+ }+ }+ warn_if_no_description(mpn);+ Ok(())+}++/// Best-effort enrichment from the Adom parts tools after a fetch. Quietly+/// no-ops if adom-parts-search isn't reachable.+fn auto_enrich(mpn: &str) {+ let _ = hints::heartbeat(mpn, "enriching from adom-parts-search");+ match crate::enrich::enrich(mpn) {+ Ok(r) => {+ if let Some(d) = &r.description {+ println!(" enrich: description set — {}", d.chars().take(70).collect::<String>());+ }+ if r.wrote_stock { println!(" enrich: stock.json written (stock + pricing + lifecycle)"); }+ }+ Err(e) => println!(" enrich: skipped ({e})"),+ }+}++/// Read a chip's info.json.description (empty if none).+fn chip_description(mpn: &str) -> String {+ let p = library::root().join(mpn).join("info.json");+ std::fs::read_to_string(&p).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("description").and_then(|d| d.as_str()).map(String::from))+ .unwrap_or_default()+}++/// After sourcing, nudge the AI to set a human description if the chip has none+/// — the card + zoom view show it, and a blank "what is this chip" reads as+/// broken. Self-enforcing, same spirit as the gate/completeness warnings.+fn warn_if_no_description(mpn: &str) {+ if chip_description(mpn).trim().is_empty() {+ println!("HINT: {mpn} has no description — the card/zoom view will show a blank \+ 'what is this chip'. Set one: `adom-chip-fetcher describe {mpn} \"<one-line what it is>\"`");+ }+}++/// Run adom-ds2sf on a chip — the datasheet IS the most authoritative source.+/// Hard-guarantees a real datasheet first, then extracts + renders the variant.+fn cmd_ds2sf(mpn: &str) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.exists() { anyhow::bail!("library/{mpn}/ does not exist"); }+ let pdf = dir.join(format!("{mpn}.pdf"));+ // GUARANTEE the datasheet is usable BEFORE spending a Claude extraction on it.+ match crate::import::datasheet_ready(&pdf) {+ Ok(q) => println!("OK: datasheet ready — {} pages, pinout detected", q.pages),+ Err(reason) => {+ hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf — {reason}. \+ Re-fetch a real datasheet (`adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <mfr lit URL>`), then re-run."));+ anyhow::bail!("ds2sf needs a real datasheet for {mpn}: {reason}");+ }+ }+ let _ = hints::heartbeat(mpn, "ds2sf: extracting symbol+footprint from datasheet");+ let status = std::process::Command::new("adom-ds2sf")+ .args(["extract"]).arg(&dir).arg("--force").arg("--json-only")+ .status()+ .map_err(|e| anyhow::anyhow!("could not exec adom-ds2sf (install it): {e}"))?;+ if !status.success() {+ let result = std::fs::read_to_string(dir.join(format!("{mpn}-extraction.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let why = result.as_ref().and_then(|r| r.get("summary").and_then(|s| s.as_str())).unwrap_or("see extraction.result.json");+ anyhow::bail!("ds2sf extraction failed for {mpn}: {why}");+ }+ // Render the ds2sf variant thumbnails so the carat + validation surface them.+ auto_render_thumbnails(mpn);+ let _ = hints::heartbeat(mpn, "ds2sf: extracted");+ println!("OK: {mpn} — ds2sf symbol + footprint extracted from the datasheet");+ Ok(())+}++/// Enrich a chip from the Adom parts tools and report what was written.+fn cmd_enrich(mpn: &str) -> Result<()> {+ let r = crate::enrich::enrich(mpn)?;+ match &r.description {+ Some(d) => println!("OK: {mpn} description — {d}"),+ None => println!("WARN: {mpn} — no description found via adom-parts-search"),+ }+ if r.wrote_stock { println!("OK: {mpn} stock.json written (stock + pricing + lifecycle)"); }+ if !r.variants.is_empty() {+ println!("OK: {mpn} orderable variants: {}", r.variants.join(", "));+ }+ Ok(())+}++/// Write the human description into library/<MPN>/info.json (creating the file+/// if needed). Shown on the dashboard card and the zoom focus view.+fn cmd_describe(mpn: &str, text: &str) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.exists() {+ anyhow::bail!("library/{mpn}/ does not exist — source the chip first");+ }+ let p = dir.join("info.json");+ let mut info: serde_json::Value = std::fs::read_to_string(&p).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| serde_json::json!({}));+ info["description"] = serde_json::Value::String(text.to_string());+ std::fs::write(&p, serde_json::to_string_pretty(&info)?)?;+ println!("OK: {mpn} description set ({} chars)", text.len());+ Ok(())+}++/// First-class credential awareness: scan saved logins across every browser+/// profile, map to the ladder, report coverage + the best profile. The sourcing+/// flow auto-switches profiles per vendor using the same data.+fn cmd_logins() -> Result<()> {+ use std::collections::{BTreeMap, BTreeSet};+ let creds = pup::credentials();+ let mut by: BTreeMap<String, BTreeMap<&str, BTreeSet<String>>> = BTreeMap::new();+ for c in creds {+ for (frag, name) in pup::VENDOR_HOSTS {+ if c.host.contains(frag) {+ by.entry(format!("{} · {}", c.browser, c.profile))+ .or_default().entry(*name).or_default()+ .insert(if c.username.is_empty() { "(saved)".into() } else { c.username.clone() });+ }+ }+ }+ if by.is_empty() {+ println!("No saved CAD/distributor logins found in the native browser.");+ println!("HINT: install/enable the Adom browser extension, or sign in to a vendor once.");+ return Ok(());+ }+ let mut ranked: Vec<_> = by.into_iter().collect();+ ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));+ println!("adom-chip-fetcher — saved CAD/distributor logins (read-only; never passwords):");+ for (prof, vmap) in &ranked {+ println!("\n [{prof}] — {} vendor(s):", vmap.len());+ for (name, users) in vmap {+ println!(" {name:16} {}", users.iter().cloned().collect::<Vec<_>>().join(", "));+ }+ }+ let (best, bv) = &ranked[0];+ println!("\nBEST profile for sourcing: {best} ({} vendors). adom-chip-fetcher auto-switches \+ to the right signed-in profile per vendor as it sources — no questions.", bv.len());++ // Per-BROWSER score: which native browser (Chrome vs Edge) covers the most+ // of the electronics industry across all its profiles.+ let total_vendors = pup::VENDOR_HOSTS.iter().map(|(_, n)| *n).collect::<BTreeSet<_>>().len();+ let mut by_browser: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();+ for c in creds {+ for (frag, name) in pup::VENDOR_HOSTS {+ if c.host.contains(frag) { by_browser.entry(c.browser.clone()).or_default().insert(*name); }+ }+ }+ let mut bscore: Vec<_> = by_browser.into_iter().collect();+ bscore.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));+ println!("\n=== Native-browser industry-coverage score ===");+ for (br, v) in &bscore {+ println!(" {:6} {}/{} vendors ({})", br, v.len(), total_vendors, v.iter().cloned().collect::<Vec<_>>().join(", "));+ }+ if let Some((winner, v)) = bscore.first() {+ println!("\nWINNER: drive in **{winner}** — broadest electronics-industry login coverage ({}/{} vendors).", v.len(), total_vendors);+ }+ Ok(())+}++/// Seed the user-visible plan + planned-state cards BEFORE any scraping, so the+/// user sees the whole queue up front. Required first step of every batch.+fn cmd_plan(mpns: &[String], name: &str) -> Result<()> {+ if mpns.is_empty() {+ anyhow::bail!("no MPNs given.\n Usage: adom-chip-fetcher plan <MPN1> <MPN2> ... [--name <plan>]");+ }+ let _ = crate::projects::create_project(name, "adom-chip-fetcher plan");+ crate::projects::set_active(Some(name))?;+ if let Err(e) = hints::dashboard_ensure(false) {+ println!("WARN: could not auto-open the dashboard ({e}); start it with `adom-chip-fetcher serve`");+ }+ println!("PLAN '{name}' — {} chip(s) queued (the user sees these as planned cards now):", mpns.len());+ for (i, mpn) in mpns.iter().enumerate() {+ let _ = crate::projects::add_mpn(name, mpn);+ let _ = hints::heartbeat(mpn, "planned"); // side-effect: seeds the stub card+ println!(" {}. {mpn}", i + 1);+ }+ println!("\nNow source them — manufacturer-first, and every verb heartbeats live to the dashboard:");+ println!(" adom-chip-fetcher mfr-probe <MPN> # required first source (the maker)");+ println!(" adom-chip-fetcher next-source <MPN> # the next ladder step to try");+ println!(" adom-chip-fetcher gate # refuses 'done' while any card is incomplete");+ Ok(())+}++/// PREFLIGHT GUARD — every browser-driving verb (fetch / mfr-probe / scrape)+/// calls this FIRST. It refuses (non-zero exit + HINT) to scrape any MPN the+/// user hasn't seen in an active plan, and refuses if the dashboard isn't live+/// to show working status. This is the programmatic backstop that makes+/// "willy-nilly AI scraping with no insight" structurally impossible — for this+/// agent and for every third party that drives the tool.+fn require_planned(mpn: &str) -> Result<()> {+ let active = crate::projects::get_active().ok_or_else(|| anyhow::anyhow!(+ "REFUSED: no active plan — the user must see the queue before any scraping.\n \+ HINT: adom-chip-fetcher plan {mpn} [more MPNs...] then source."+ ))?;+ if !crate::projects::mpns_for_project(&active).iter().any(|m| m == mpn) {+ anyhow::bail!(+ "REFUSED: {mpn} is not in the active plan '{active}'. Don't scrape chips the user never saw planned.\n \+ HINT: adom-chip-fetcher plan {mpn} --name {active}"+ );+ }+ if !dashboard_reachable() {+ anyhow::bail!(+ "REFUSED: the dashboard isn't reachable on :8786, so the user would get no working-status insight.\n \+ HINT: adom-chip-fetcher serve (then retry) — the dashboard is the user's home base."+ );+ }+ Ok(())+}++fn dashboard_reachable() -> bool {+ use std::net::TcpStream;+ use std::time::Duration;+ "127.0.0.1:8786"+ .parse()+ .ok()+ .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok())+ .is_some()+}+ fn project_cmd(op: ProjectOp) -> Result<()> { use crate::projects; match op {@@ -443,7 +755,7 @@ fn project_cmd(op: ProjectOp) -> Result<()> { let active = projects::get_active(); let projs = projects::list_projects(); if projs.is_empty() {- println!("no projects. create one with: chip-fetcher project create <name>");+ println!("no projects. create one with: adom-chip-fetcher project create <name>"); } for p in &projs { let marker = if active.as_deref() == Some(&p.name) { " (active)" } else { "" };@@ -506,7 +818,7 @@ fn thumbnails_cmd(mpn: Option<&str>, force: bool) -> Result<()> { } println!(); println!("OK: {} chips processed — {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len());- hints::print_hint("open the dashboard to see them: `chip-fetcher dashboard ensure`");+ hints::print_hint("open the dashboard to see them: `adom-chip-fetcher dashboard ensure`"); } Ok(()) }@@ -539,13 +851,13 @@ fn dashboard_dispatch(op: DashboardOp) -> Result<()> { _ => anyhow::bail!("surface must be 'pup' or 'webview'"), }; hints::set_dashboard_surface(parsed)?;- hints::print_hint("verify with `chip-fetcher dashboard ensure`");+ hints::print_hint("verify with `adom-chip-fetcher dashboard ensure`"); Ok(()) } DashboardOp::Surface => { let s = hints::get_dashboard_surface(); println!("dashboard surface: {:?}", s);- hints::print_hint("change with `chip-fetcher dashboard set-surface pup|webview`");+ hints::print_hint("change with `adom-chip-fetcher dashboard set-surface pup|webview`"); Ok(()) } }@@ -581,8 +893,8 @@ fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { }), (4, "Altium library", c.has_altium || c.altium_not_available, if c.has_altium { "" } else if c.altium_not_available { "(not available from any source)" } else { "fetch .IntLib or .SchLib+.PcbLib from SnapMagic/CSE" }), (5, "Fusion .lbr", c.has_fusion_lbr || c.fusion_not_available, if c.has_fusion_lbr { "" } else if c.fusion_not_available { "(not available from any source)" } else { "fetch .lbr from SnapMagic/CSE" }),- (6, "Thumbnails", has_thumbs, if has_thumbs { "" } else { "`chip-fetcher thumbnails --mpn <MPN> --force`" }),- (7, "Validated", val_golden, if val_golden { "" } else if has_val_status { "re-run: `chip-fetcher validate <MPN>`" } else { "run: `chip-fetcher validate <MPN>` (needs PDF + sym + mod)" }),+ (6, "Thumbnails", has_thumbs, if has_thumbs { "" } else { "`adom-chip-fetcher thumbnails --mpn <MPN> --force`" }),+ (7, "Validated", val_golden, if val_golden { "" } else if has_val_status { "re-run: `adom-chip-fetcher validate <MPN>`" } else { "run: `adom-chip-fetcher validate <MPN>` (needs PDF + sym + mod)" }), (8, "Mouser stock", has_stock, if has_stock { "" } else { "fetch stock.json from Mouser API or product page" }), (9, "File provenance", has_prov, if has_prov { "" } else { "re-import with --discovery-source + --content-origin flags" }), ];@@ -621,73 +933,49 @@ fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { // ---- Hardcoded profile for all pup scraping ---- // The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser,-// DigiKey) live in the "chip-fetcher" Chrome profile. Creating ANY+// DigiKey) live in the "adom-chip-fetcher" Chrome profile. Creating ANY // other profile loses every credential. This constant is the single // source of truth — every scrape/refetch/pup-pdf-fetch path uses it.-const SCRAPE_PROFILE: &str = "chip-fetcher";-const SCRAPE_SESSION: &str = "chip-fetcher-scrape";+const SCRAPE_PROFILE: &str = "adom-chip-fetcher";+const SCRAPE_SESSION: &str = "adom-chip-fetcher-scrape"; fn scrape_cmd(op: ScrapeOp) -> Result<()> { match op { ScrapeOp::Open { mpn, url } => {+ require_planned(&mpn)?; let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)]));- let body = serde_json::json!({- "sessionId": SCRAPE_SESSION,- "profile": SCRAPE_PROFILE,- "url": url,- }).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_open_window", &body])- .output()?;- if !out.status.success() {- let nav = serde_json::json!({"sessionId": SCRAPE_SESSION, "url": url}).to_string();- let _ = std::process::Command::new("adom-desktop")- .args(["browser_navigate", &nav])- .output()?;- }- println!("OK: scrape window open at {url} (profile={SCRAPE_PROFILE})");- hints::print_hint(&format!("page is loading. Next: `chip-fetcher scrape eval {mpn} '<JS to find what you need>'` to inspect the page, or `chip-fetcher scrape screenshot {mpn}` to see what the user sees."));+ // ONE window: the scrape tab lives beside the dashboard in the+ // adom-chip-fetcher window (native) — no separate scrape window.+ pup::scrape_navigate(&url)?;+ println!("OK: scrape tab open at {url} ({})", pup::surface_label());+ hints::print_hint(&format!("page is loading. Next: `adom-chip-fetcher scrape eval {mpn} '<JS>'` to inspect, or `adom-chip-fetcher scrape screenshot {mpn}`.")); Ok(()) } ScrapeOp::Navigate { mpn, url, stage } => {+ require_planned(&mpn)?; let label = stage.unwrap_or_else(|| format!("navigating {}", &url[..url.len().min(50)])); let _ = hints::heartbeat(&mpn, &label);- let body = serde_json::json!({"sessionId": SCRAPE_SESSION, "url": url}).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_navigate", &body])- .output()?;- if !out.status.success() {- anyhow::bail!("browser_navigate failed");- }+ pup::scrape_navigate(&url)?; println!("OK: navigated to {url}");- hints::print_hint(&format!("page loaded. Next: eval JS to find download buttons/links, or screenshot to see the page. Remember: check `chip-fetcher next-source {mpn}` if this source doesn't have what you need."));+ hints::print_hint(&format!("page loaded. Next: eval JS to find download links, or screenshot. Check `adom-chip-fetcher next-source {mpn}` if this source lacks what you need.")); Ok(()) } ScrapeOp::Eval { mpn, expr } => { let short = if expr.len() > 40 { format!("{}…", &expr[..40]) } else { expr.clone() }; let _ = hints::heartbeat(&mpn, &format!("scrape eval: {short}"));- let body = serde_json::json!({"sessionId": SCRAPE_SESSION, "expr": expr}).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_eval", &body])- .output()?;- let v: serde_json::Value = serde_json::from_slice(&out.stdout)- .unwrap_or(serde_json::Value::Null);- let result = v.get("result").cloned().unwrap_or(serde_json::Value::Null);+ let result = pup::scrape_eval(&expr)?; println!("{}", serde_json::to_string_pretty(&result)?);- hints::print_hint(&format!("read the result above. If you found a download URL, use `chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>` for PDFs or `chip-fetcher refetch {mpn} --format <fmt> --source <src>` for CAD bundles."));+ hints::print_hint(&format!("read the result. For a PDF URL use `adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>`; for CAD use `adom-chip-fetcher refetch {mpn} --format <fmt> --source <src>`.")); Ok(()) } ScrapeOp::Screenshot { mpn } => { let _ = hints::heartbeat(&mpn, "scrape: taking screenshot");- let body = serde_json::json!({"sessionId": SCRAPE_SESSION}).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_screenshot", &body])- .output()?;- let v: serde_json::Value = serde_json::from_slice(&out.stdout)- .unwrap_or(serde_json::Value::Null);- let path = v.get("savedTo").and_then(|p| p.as_str()).unwrap_or("?");- println!("OK: screenshot at {path}");- hints::print_hint("look at the screenshot to see what the user sees. If a modal or interstitial is blocking, dismiss it with `scrape eval` before proceeding.");+ let v = pup::scrape_screenshot()?;+ let path = v.get("savedTo").and_then(|p| p.as_str())+ .or_else(|| v.get("output").and_then(|o| o.as_str()))+ .unwrap_or("(captured)");+ println!("OK: screenshot {path}");+ hints::print_hint("look at the screenshot. If a modal blocks, dismiss it with `scrape eval` first."); Ok(()) } }@@ -700,7 +988,7 @@ fn creds_cmd(op: CredsOp) -> Result<()> { if creds.is_empty() { println!("no stored credentials"); println!();- hints::print_hint("save creds with: chip-fetcher creds save <host> <username> <password>");+ hints::print_hint("save creds with: adom-chip-fetcher creds save <host> <username> <password>"); hints::print_hint("hosts: snapeda, ultralib, componentsearch, ti, st, microchip, google"); } else { println!("stored credentials ({}):", creds.len());@@ -716,7 +1004,7 @@ fn creds_cmd(op: CredsOp) -> Result<()> { CredsOp::Save { host, username, password } => { crate::creds::set(&host, &username, &password)?; println!("OK: saved credential for {host} (user={username})");- println!("stored at ~/.config/chip-fetcher/credentials.json");+ println!("stored at ~/.config/adom-chip-fetcher/credentials.json"); Ok(()) } CredsOp::Delete { host } => {@@ -737,7 +1025,7 @@ fn creds_cmd(op: CredsOp) -> Result<()> { /// was 47 KB of <!DOCTYPE html> from an LCSC mirror that 404'd. /// /// On --repair: deletes the bogus PDF + appends an `html_masquerade`-/// failure entry to info.json.fetched_via_chain so `chip-fetcher+/// failure entry to info.json.fetched_via_chain so `adom-chip-fetcher /// next-source` correctly advances to the next ladder step. fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> { let lib = library::root();@@ -826,7 +1114,7 @@ fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> { } println!(); if repair {- hints::print_hint("bogus PDFs deleted + info.json.fetched_via_chain updated. Run `chip-fetcher next-source <MPN>` to see what to try next.");+ hints::print_hint("bogus PDFs deleted + info.json.fetched_via_chain updated. Run `adom-chip-fetcher next-source <MPN>` to see what to try next."); } else { hints::print_hint("re-run with --repair to delete bogus PDFs + record html_masquerade in fetched_via_chain. Then refetch from next ladder source."); }@@ -871,111 +1159,48 @@ fn pup_pdf_fetch_cmd( other => anyhow::bail!("unknown source slug {other:?}"), }; - // Pick the navigation URL: prefer page_url, then a known datasheet_url- // from info.json, then fall through to mfr-probe.- let nav_url = if let Some(p) = page_url {- p.to_string()- } else if pdf_url.is_some() {- // We have a direct PDF URL but no page; navigate to the chip's- // mfr search page anyway so cookies + Referer are realistic- // (some CDNs check Referer host).- sourcing::guess_mfr(mpn)- .map(|g| g.search_url)- .unwrap_or_else(|| pdf_url.unwrap().to_string())- } else {- sourcing::guess_mfr(mpn)- .map(|g| g.search_url)- .ok_or_else(|| anyhow::anyhow!("no --page-url, no --pdf-url, and could not infer manufacturer for {mpn}; pass --page-url <url>"))?- };-- let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: opening pup"));-- // Open or focus the pup session.- let open_body = serde_json::json!({- "sessionId": session,- "profile": session,- "url": nav_url,- }).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_open_window", &open_body])- .output()?;- if !out.status.success() {- // Already-open sessions return error — try a navigate instead.- let nav_body = serde_json::json!({- "sessionId": session,- "url": nav_url,- }).to_string();- let _ = std::process::Command::new("adom-desktop")- .args(["browser_navigate", &nav_body])- .output()?;- }- println!("OK: pup at {nav_url}");- std::thread::sleep(std::time::Duration::from_secs(4));-- // If --pdf-url not given, search the rendered page for one matching the MPN.+ let _ = session; // legacy arg; native fetch uses the one adom-chip-fetcher window+ // Resolve the PDF URL: prefer --pdf-url; else open --page-url (or the mfr+ // product page) in the adom-chip-fetcher window's scrape tab and read the+ // datasheet anchor off the rendered page. Everything stays in ONE window. let resolved_pdf = if let Some(p) = pdf_url { p.to_string() } else {- let find_expr = format!(- "(()=>{{const m={:?};const aa=Array.from(document.querySelectorAll('a'));\- const matches=aa.filter(a=>{{const u=(a.href||'').toLowerCase();const t=(a.textContent||'').toLowerCase();\- return u.includes('.pdf') || /datasheet|spec|文件|drawing/.test(t)}});\- const best=matches.find(a=>(a.href+a.textContent).toLowerCase().includes(m.toLowerCase()))||matches[0];\- return best?best.href:''}})()",- mpn- );- let find_body = serde_json::json!({"sessionId": session, "expr": find_expr}).to_string();- let find_out = std::process::Command::new("adom-desktop")- .args(["browser_eval", &find_body])- .output()?;- let find_v: serde_json::Value = serde_json::from_slice(&find_out.stdout)?;- let url = find_v.get("result").and_then(|r| r.as_str()).unwrap_or("").to_string();+ let nav_url = page_url.map(|s| s.to_string())+ .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.search_url))+ .ok_or_else(|| anyhow::anyhow!("pass --pdf-url or --page-url for {mpn}"))?;+ let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: finding datasheet"));+ pup::scrape_navigate(&nav_url)?;+ std::thread::sleep(std::time::Duration::from_secs(4));+ let r = pup::scrape_eval(+ "(function(){var a=[...document.querySelectorAll('a')].find(function(x){return /\\/resource\\/.*\\.pdf/i.test(x.href)||(/datasheet/i.test(x.textContent)&&/\\.pdf/i.test(x.href))});return a?a.href:''})()"+ )?;+ let url = r.as_str().unwrap_or("").to_string(); if url.is_empty() { let _ = sourcing::append_chain(mpn, source_enum, "pup_no_pdf_link",- Some(&format!("page {nav_url} had no anchor with .pdf or 'datasheet'/'文件' text matching {mpn}")));- anyhow::bail!("could not find a PDF anchor on {nav_url} matching {mpn}; pass --pdf-url <direct-url>");+ Some(&format!("no datasheet anchor on {nav_url}")));+ anyhow::bail!("no datasheet link found on {nav_url}; pass --pdf-url <direct-url>"); }- println!("OK: found PDF anchor: {url}");+ println!("OK: found datasheet: {url}"); url }; - // Fetch the PDF bytes inside the browser context (carries cookies +- // proper Referer), arrayBuffer → base64 → return via stdout.- let fetch_expr = format!(- "(async()=>{{const r=await fetch({:?},{{credentials:'include'}});\- if(!r.ok)return 'http '+r.status;\- const b=await r.arrayBuffer();const u8=new Uint8Array(b);let s='';\- for(let i=0;i<u8.length;i+=8192){{s+=String.fromCharCode.apply(null,u8.subarray(i,i+8192))}}\- return btoa(s)}})()",- resolved_pdf- );- let fetch_body = serde_json::json!({"sessionId": session, "expr": fetch_expr}).to_string();- let fetch_out = std::process::Command::new("adom-desktop")- .args(["browser_eval", &fetch_body])- .output()?;- let fetch_v: serde_json::Value = serde_json::from_slice(&fetch_out.stdout)?;- let b64 = fetch_v.get("result").and_then(|r| r.as_str()).unwrap_or("");-- if b64.is_empty() || !b64.starts_with("JVBERi") {- // JVBERi is base64('%PDF') — a real PDF will start with this.- let preview = if b64.len() > 80 { &b64[..80] } else { b64 };+ // Fetch the bytes THROUGH the signed-in browser (real cookies, no download,+ // no native viewer) straight into the library.+ let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: downloading datasheet"));+ let target = dir.join(format!("{mpn}.pdf"));+ let (n, ctype) = pup::fetch_url_to_file(&resolved_pdf, &target)?;+ let head: Vec<u8> = std::fs::read(&target).ok().map(|b| b.into_iter().take(4).collect()).unwrap_or_default();+ if head != b"%PDF" {+ std::fs::remove_file(&target).ok(); let _ = sourcing::append_chain(mpn, source_enum, "pup_fetch_not_pdf",- Some(&format!("fetch returned non-PDF bytes (b64 prefix: {})", preview)));- anyhow::bail!("fetched bytes are not a PDF (base64 prefix: {})", preview);- }-- // Base64 → bytes → magic-byte recheck → write.- let pdf_bytes = base64_decode(b64)- .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?;- if !pdf_bytes.starts_with(b"%PDF") {- anyhow::bail!("decoded bytes don't start with %PDF (decoder integrity issue)");+ Some(&format!("content-type {ctype}, {n} bytes, head {:?}", String::from_utf8_lossy(&head))));+ anyhow::bail!("fetched bytes are not a PDF (content-type {ctype}, {n} bytes)"); }- let target = dir.join(format!("{mpn}.pdf"));- std::fs::write(&target, &pdf_bytes)?;- println!("OK: wrote {} ({} KB, {})", target.display(), pdf_bytes.len() / 1024, source);-+ println!("OK: wrote {} ({} KB, {source})", target.display(), n / 1024); let _ = sourcing::append_chain(mpn, source_enum, "OK",- Some(&format!("pup-pdf-fetch via {} → {} ({} KB), %PDF magic verified", session, resolved_pdf, pdf_bytes.len() / 1024)));+ Some(&format!("datasheet {} KB via fetch_url, %PDF verified", n / 1024)));+ let _ = hints::heartbeat(mpn, "imported"); Ok(()) } @@ -1013,7 +1238,7 @@ fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> { } /// Re-fetch driver. Opens the canonical URL for (source, MPN) in the-/// chip-fetcher-scrape pup session, sets up a desktop_watch_files for the+/// adom-chip-fetcher-scrape pup session, sets up a desktop_watch_files for the /// expected download, waits up to `timeout` seconds, then imports the /// resulting file with magic-byte validation. Records every attempt on /// `info.json.fetched_via_chain` so the audit trail shows which sources@@ -1052,8 +1277,8 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u // Real refetch — open URL, watch downloads, import. let _ = hints::heartbeat(mpn, &format!("refetch {format} from {source}: opening pup")); let body = serde_json::json!({- "sessionId": "chip-fetcher-scrape",- "profile": "chip-fetcher",+ "sessionId": "adom-chip-fetcher-scrape",+ "profile": "adom-chip-fetcher", "url": url, }).to_string(); let out = std::process::Command::new("adom-desktop")@@ -1105,7 +1330,7 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u } /// Map (source, MPN) → canonical URL. Manufacturer uses the prefix table-/// from sourcing::guess_mfr (so it's the same URL as `chip-fetcher+/// from sourcing::guess_mfr (so it's the same URL as `adom-chip-fetcher /// mfr-probe`); third-party sources have hardcoded search URL templates. fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> { let enc = mpn.replace(' ', "%20");@@ -1127,12 +1352,12 @@ fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> { /// Validate driver. Resolves MPN → chip directory under library/<MPN>/, /// runs the ds2sf → concur loop, prints the outcome. Returns non-zero /// only on hard error or when the loop ends in NeedsHuman /-/// Ds2sfUnrecoverable — so a CI-style caller can `&& chip-fetcher+/// Ds2sfUnrecoverable — so a CI-style caller can `&& adom-chip-fetcher /// validate <MPN>` and trust the exit code. fn validate_cmd(mpn: &str) -> Result<()> { let dir = library::root().join(mpn); if !dir.is_dir() {- anyhow::bail!("no chip directory at {} — run chip-fetcher first", dir.display());+ anyhow::bail!("no chip directory at {} — run adom-chip-fetcher first", dir.display()); } let outcome = validate::validate_chip_dir(&dir)?; validate::print_outcome(&outcome);@@ -1149,7 +1374,7 @@ fn validate_cmd(mpn: &str) -> Result<()> { } /// Manufacturer-probe. Detects the manufacturer from an MPN prefix and-/// opens their canonical product/search page in the chip-fetcher-scrape+/// opens their canonical product/search page in the adom-chip-fetcher-scrape /// pup session. Records the attempt on info.json.fetched_via_chain so /// next-source knows manufacturer has been tried. AI must run this /// BEFORE any aggregator — that's the rule, now enforced.@@ -1165,25 +1390,20 @@ fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> { "attempted", Some(&format!("auto-probe → {}", g.manufacturer))); if !no_open {- let body = serde_json::json!({- "sessionId": "chip-fetcher-scrape",- "profile": "chip-fetcher",- "url": g.search_url,- }).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_open_window", &body])- .output();- match out {- Ok(o) if o.status.success() => println!("OK: opened {} in chip-fetcher-scrape pup session", g.search_url),- _ => hints::print_hint("could not auto-open via adom-desktop — open the URL above manually"),+ // Manufacturer-first source opens as a TAB in the ONE adom-chip-fetcher+ // window (beside the dashboard tab), via the native browser when+ // available so ST/TI/Nordic logins + trust apply.+ match pup::open_site(SESSION, PROFILE, &g.search_url) {+ Ok(_) => println!("OK: opened {} in the adom-chip-fetcher window ({})", g.search_url, pup::surface_label()),+ Err(e) => hints::print_hint(&format!("could not auto-open ({e}) — open the URL above manually")), } }- hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates."));+ hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `adom-chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates.")); Ok(()) } None => { println!("WARN: could not infer manufacturer for {mpn} from prefix table.");- hints::print_hint(&format!("identify the manufacturer manually (datasheet header, distributor product page) and open <mfr>.com directly. Then heartbeat: `chip-fetcher heartbeat {mpn} \"probing <vendor>.com\"` so the chain records it."));+ hints::print_hint(&format!("identify the manufacturer manually (datasheet header, distributor product page) and open <mfr>.com directly. Then heartbeat: `adom-chip-fetcher heartbeat {mpn} \"probing <vendor>.com\"` so the chain records it.")); Ok(()) } }@@ -1207,11 +1427,11 @@ fn next_source_cmd(mpn: &str) -> Result<()> { Some(s) => { println!("NEXT: try {} next.", s.as_slug()); if matches!(s, sourcing::Source::Manufacturer) {- hints::print_hint(&format!("`chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup."));+ hints::print_hint(&format!("`adom-chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup.")); } else if matches!(s, sourcing::Source::Lcsc) { hints::print_hint(&format!("LCSC is the LAST step — only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC.")); } else {- hints::print_hint(&format!("heartbeat `chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug()));+ hints::print_hint(&format!("heartbeat `adom-chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug())); } } None => {@@ -1239,18 +1459,34 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { return Ok(()); } - let mut incomplete: Vec<(String, Vec<&'static str>)> = Vec::new();+ let mut incomplete: Vec<(String, Vec<String>)> = Vec::new(); let mut complete = 0usize; for c in &chips {- let mut missing: Vec<&'static str> = Vec::new();- if !c.has_pdf { missing.push("datasheet.pdf"); }- if !c.has_step { missing.push("STEP"); }- if !c.has_sym { missing.push("KiCad .kicad_sym"); }- if !c.has_mod { missing.push("KiCad .kicad_mod"); }+ let dir = library::root().join(&c.mpn);+ let pdf = dir.join(format!("{}.pdf", c.mpn));+ let mut missing: Vec<String> = Vec::new();+ if !c.has_pdf { missing.push("datasheet.pdf".into()); }+ if !c.has_step { missing.push("STEP".into()); }+ if !c.has_sym { missing.push("KiCad .kicad_sym".into()); }+ if !c.has_mod { missing.push("KiCad .kicad_mod".into()); }+ // PERMANENT GUARANTEE 1 — the datasheet must be REAL (multi-page + pinout),+ // not a 1-page denial page that silently breaks ds2sf.+ if c.has_pdf {+ if let Err(reason) = crate::import::datasheet_ready(&pdf) {+ missing.push(format!("GOOD datasheet — {reason}"));+ }+ }+ // PERMANENT GUARANTEE 2 — ds2sf (the most authoritative source: the+ // manufacturer datasheet extraction) must have RUN and SUCCEEDED.+ let ds_ok = std::fs::read_to_string(dir.join(format!("{}-extraction.result.json", c.mpn))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|r| r.get("status").and_then(|x| x.as_str()).map(|s| s == "ok"))+ .unwrap_or(false);+ if !ds_ok { missing.push("ds2sf extraction (symbol+footprint from the datasheet)".into()); } if strict {- if !c.has_altium { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)"); }- if !c.has_fusion_lbr { missing.push("Fusion .lbr"); }+ if !c.has_altium { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)".into()); }+ if !c.has_fusion_lbr { missing.push("Fusion .lbr".into()); } } if missing.is_empty() { complete += 1;@@ -1271,11 +1507,11 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { println!(); for (mpn, missing) in &incomplete { for m in missing {- hints::print_hint(&format!("{mpn} → fetch {m} (see `chip-fetcher sources` for the ladder, then `chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)"));+ hints::print_hint(&format!("{mpn} → fetch {m} (see `adom-chip-fetcher sources` for the ladder, then `adom-chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)")); } } println!();- hints::print_hint(&format!("re-run `chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.",+ hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.", if strict { " --strict" } else { "" })); anyhow::bail!("gate FAILED: {} of {total} cards incomplete", incomplete.len()); }@@ -1287,7 +1523,7 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { fn sources_cmd() -> Result<()> { // Single source of truth for the sourcing ladder. Both this command and // hints::sourcing_ladder_lines() read from here, so playbook + binary stay- // in sync. AI consumers can call `chip-fetcher sources` and parse the+ // in sync. AI consumers can call `adom-chip-fetcher sources` and parse the // ordered list — no need to re-read the playbook prose. println!("Sourcing-ladder priority (always go in order, never skip):\n"); for (i, (name, note)) in hints::SOURCE_LADDER.iter().enumerate() {@@ -1302,15 +1538,15 @@ fn sources_cmd() -> Result<()> { fn hint_cmd() -> Result<()> { match hints::last() { None => {- hints::print_hint("no heartbeat yet — kick off with `chip-fetcher heartbeat <MPN> \"starting fetch\"`");- hints::print_hint("then `chip-fetcher selfcheck` to verify dashboard + pup session.");+ hints::print_hint("no heartbeat yet — kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`");+ hints::print_hint("then `adom-chip-fetcher selfcheck` to verify dashboard + pup session."); } Some(hb) => { let age = hints::age_secs(&hb); println!("last heartbeat: {} → {:?} ({}s ago)", hb.mpn, hb.stage, age); if age > 60 { hints::print_hint(&format!(- "stale heartbeat — refresh: `chip-fetcher heartbeat {} \"<current stage>\"`",+ "stale heartbeat — refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`", hb.mpn )); } else {@@ -1323,14 +1559,14 @@ fn hint_cmd() -> Result<()> { hints::print_next_after(&hb.stage, Some(&hb.mpn)); } else { hints::print_hint(&format!(- "heartbeat the next stage transition: `chip-fetcher heartbeat {} \"<next stage>\"`",+ "heartbeat the next stage transition: `adom-chip-fetcher heartbeat {} \"<next stage>\"`", hb.mpn )); hints::print_hint(&format!(- "after import lands: `chip-fetcher heartbeat {} imported` then `chip-fetcher status {}`",+ "after import lands: `adom-chip-fetcher heartbeat {} imported` then `adom-chip-fetcher status {}`", hb.mpn, hb.mpn ));- hints::print_hint("if anything is off-rails: `chip-fetcher selfcheck`");+ hints::print_hint("if anything is off-rails: `adom-chip-fetcher selfcheck`"); } } }@@ -1355,11 +1591,11 @@ fn app_cmd(port: u16) -> Result<()> { fn install_cmd() -> Result<()> { let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;- let skill_dir = std::path::PathBuf::from(home).join(".claude/skills/chip-fetcher");+ let skill_dir = std::path::PathBuf::from(home).join(".claude/skills/adom-chip-fetcher"); std::fs::create_dir_all(&skill_dir)?; let skill_path = skill_dir.join("SKILL.md"); std::fs::write(&skill_path, SKILL_MD)?;- println!("OK: chip-fetcher SKILL.md deployed to {}", skill_path.display());+ println!("OK: adom-chip-fetcher SKILL.md deployed to {}", skill_path.display()); println!(" {} bytes, {} lines", SKILL_MD.len(), SKILL_MD.lines().count()); // Deploy playbooks/ alongside SKILL.md. Each playbook is a focused deep-dive Claude@@ -1382,7 +1618,7 @@ fn install_cmd() -> Result<()> { println!(" {total_bytes} bytes, {total_lines} lines across all playbooks"); println!();- println!("Try `chip-fetcher --help` to see commands.");+ println!("Try `adom-chip-fetcher --help` to see commands."); Ok(()) } @@ -1444,7 +1680,7 @@ fn run_post_import_validate(dir: &std::path::Path, mpn: &str) { println!("running validation (ds2sf + concur) — gold-dataset gate"); match validate::validate_chip_dir(dir) { Ok(outcome) => validate::print_outcome(&outcome),- Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `chip-fetcher validate {mpn}`"),+ Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `adom-chip-fetcher validate {mpn}`"), } } @@ -1484,14 +1720,14 @@ fn fetch(mpn: &str) -> Result<()> { println!("\nstep 1: on the TI page, click \"CAD/CAE Symbols & Footprints\" → opens UL with the part loaded"); println!("step 2: in UL — pick variant, check STEP + KiCAD v6+, set units to mm, click Submit");- println!("step 3: chip-fetcher will auto-pull the download (up to 5 min wait)");+ println!("step 3: adom-chip-fetcher will auto-pull the download (up to 5 min wait)"); println!(); let local = match wait_for_download() { Ok(p) => p, Err(e) => { eprintln!("auto-pull failed: {e}");- eprintln!("fallback: drag the zip to chip-fetcher/incoming/ and run `chip-fetcher import`");+ eprintln!("fallback: drag the zip to adom-chip-fetcher/incoming/ and run `adom-chip-fetcher import`"); return Err(e); } };@@ -1520,7 +1756,7 @@ fn fetch(mpn: &str) -> Result<()> { let dir = library::root().join(mpn); match validate::validate_chip_dir(&dir) { Ok(outcome) => validate::print_outcome(&outcome),- Err(e) => println!("WARN: validate failed: {e} — re-run with `chip-fetcher validate {mpn}`"),+ Err(e) => println!("WARN: validate failed: {e} — re-run with `adom-chip-fetcher validate {mpn}`"), } auto_render_thumbnails(mpn);@@ -1599,7 +1835,7 @@ fn login(source: &str) -> Result<()> { let url = match entry { Some((_, _, u)) => *u, None => anyhow::bail!(- "unknown source '{source}' — run `chip-fetcher login list` to see available sources, or `chip-fetcher login all` to open every source"+ "unknown source '{source}' — run `adom-chip-fetcher login list` to see available sources, or `adom-chip-fetcher login all` to open every source" ), }; println!("opening {url} for login — cookies will persist in profile '{PROFILE}'");@@ -1612,7 +1848,7 @@ fn list() -> Result<()> { let entries = library::scan()?; if entries.is_empty() { println!("library is empty ({})", library::root().display());- hints::print_hint("kick off a first fetch: `chip-fetcher fetch <MPN>`");+ hints::print_hint("kick off a first fetch: `adom-chip-fetcher fetch <MPN>`"); return Ok(()); } println!("{} parts in {}", entries.len(), library::root().display());
src/creds.rs+1−1@@ -18,7 +18,7 @@ pub struct PublicCred { fn store_path() -> Result<PathBuf> { let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;- let dir = PathBuf::from(home).join(".config").join("chip-fetcher");+ let dir = PathBuf::from(home).join(".config").join("adom-chip-fetcher"); std::fs::create_dir_all(&dir)?; Ok(dir.join("credentials.json")) }
src/enrich.rsadded+217@@ -0,0 +1,217 @@+//! First-class integration with the Adom parts-data tools.+//!+//! THE LESSON (learned the hard way 2026-06-23): the Adom parts tools+//! — `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` ++//! `adom-jlcpcb`) — keep their API keys baked into their OWN service containers.+//! They ALWAYS work with NO key needed. chip-fetcher must NEVER assume it needs+//! a `MOUSER_API_KEY` of its own; it just shells to `adom-parts-search`.+//!+//! What these tools give us, for free, on every chip:+//! - a human description ("what is this part")+//! - the manufacturer, image, datasheet URL, product URL+//! - stock + pricing + lead time + lifecycle → stock.json+//! - EVERY orderable package variant of a base MPN → the orderable-MPN that+//! CAD sites (CSE / UltraLibrarian) require. This is the permanent fix for+//! the "base ADS1263 → CSE Pick-One, no partID" trap.+//!+//! `adom-parts-search search <MPN>` prints unified JSON to stdout:+//! { query, mouser:{ok,total,hits:[{vendor,vendor_pn,mpn,manufacturer,+//! description,stock,unit_price,price_100,lead_time,image_url,+//! datasheet_url,product_url,flags}]}, digikey:{…}, jlcpcb:{…},+//! recommendation:{winner,reason,mpn,unit_price,stock} }++use anyhow::{Context, Result};+use serde_json::{json, Value};+use std::process::Command;++use crate::library;++const PARTS_SEARCH_BIN: &str = "adom-parts-search";++pub struct EnrichReport {+ pub mpn: String,+ pub description: Option<String>,+ pub wrote_stock: bool,+ pub variants: Vec<String>,+}++/// Run `adom-parts-search search <query>` and return the parsed JSON.+/// Tries the umbrella tool; bubbles up a clear error if it's missing.+pub fn parts_search(query: &str) -> Result<Value> {+ let out = Command::new(PARTS_SEARCH_BIN)+ .arg("search")+ .arg(query)+ .output()+ .with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}` — is adom-parts-search installed?"))?;+ let stdout = String::from_utf8_lossy(&out.stdout);+ // The CLI prints a one-line "OK: …" summary before the JSON body; find the+ // first '{' and parse from there.+ let start = stdout.find('{').ok_or_else(|| {+ anyhow::anyhow!("adom-parts-search returned no JSON for {query}: {}", stdout.trim())+ })?;+ let v: Value = serde_json::from_str(&stdout[start..])+ .with_context(|| format!("parsing adom-parts-search JSON for {query}"))?;+ Ok(v)+}++/// All hits across Mouser + DigiKey (+ JLCPCB), Mouser first.+fn all_hits(data: &Value) -> Vec<Value> {+ let mut hits = Vec::new();+ for vendor in ["mouser", "digikey", "jlcpcb"] {+ if let Some(h) = data.get(vendor).and_then(|b| b.get("hits")).and_then(|h| h.as_array()) {+ hits.extend(h.iter().cloned());+ }+ }+ hits+}++/// The orderable package variants for a (possibly base) MPN — the exact strings+/// CAD sites need. e.g. ADS1263 → ["ADS1263IPW", "ADS1263IPWR"]. Dedup,+/// preserving Mouser-first order. This is the permanent orderable-MPN resolver.+pub fn resolve_orderable(mpn: &str) -> Vec<String> {+ let data = match parts_search(mpn) {+ Ok(d) => d,+ Err(_) => return vec![],+ };+ let mut seen = std::collections::HashSet::new();+ let mut out = Vec::new();+ for h in all_hits(&data) {+ if let Some(m) = h.get("mpn").and_then(|x| x.as_str()) {+ let m = m.trim().to_string();+ if !m.is_empty() && seen.insert(m.clone()) {+ out.push(m);+ }+ }+ }+ out+}++/// Pick the best hit for `mpn`: exact MPN match first (Mouser preferred via+/// all_hits order), else the first hit.+fn best_hit(data: &Value, mpn: &str) -> Option<Value> {+ let hits = all_hits(data);+ let want = mpn.to_uppercase();+ hits.iter()+ .find(|h| h.get("mpn").and_then(|x| x.as_str()).map(|s| s.to_uppercase() == want).unwrap_or(false))+ .cloned()+ .or_else(|| hits.into_iter().next())+}++/// Clean a distributor description into a human "what is this chip" line.+/// Mouser appends vendor-PN noise like " A 595-ADS1263IPW" and a category+/// prefix "Analog to Digital Converters - "; strip both, collapse whitespace.+fn clean_description(raw: &str) -> String {+ let mut s = raw.trim().to_string();+ // Strip a trailing " A <digits>-<rest>" Mouser vendor-PN tail.+ if let Some(idx) = s.rfind(" A ") {+ let tail = &s[idx + 3..];+ if tail.chars().take(4).filter(|c| c.is_ascii_digit()).count() >= 2+ || tail.contains('-')+ {+ s.truncate(idx);+ }+ }+ // Keep a leading "Category - " prefix as context but drop it if it's the+ // generic ADC/converter family noise that just repeats the part type.+ s = s.trim_end_matches(['-', ' ', '·']).trim().to_string();+ // Collapse internal whitespace.+ s.split_whitespace().collect::<Vec<_>>().join(" ")+}++/// Build a stock.json body from the best hit.+fn stock_body(hit: &Value) -> Value {+ let get = |k: &str| hit.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();+ let stock = hit.get("stock").and_then(|x| x.as_u64()).unwrap_or(0);+ let flags: Vec<String> = hit.get("flags").and_then(|f| f.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let lifecycle = if flags.iter().any(|f| {+ let f = f.to_uppercase();+ f.contains("NRND") || f.contains("EOL") || f.contains("OBSOLETE") || f.contains("LAST TIME")+ }) { "nrnd_or_eol" } else { "active" };+ let mut tiers = Vec::new();+ let p1 = get("unit_price");+ if !p1.is_empty() { tiers.push(json!({"price": p1, "qty": 1})); }+ let p100 = get("price_100");+ if !p100.is_empty() { tiers.push(json!({"price": p100, "qty": 100})); }+ json!({+ "checked_at": "via adom-parts-search",+ "source": "adom-parts-search",+ get_vendor_key(hit): {+ "vendor_pn": get("vendor_pn"),+ "mpn": get("mpn"),+ "manufacturer": get("manufacturer"),+ "stock": stock,+ "stock_text": format!("{stock} in stock"),+ "lead_time": get("lead_time"),+ "lifecycle": lifecycle,+ "unit_price": p1,+ "price_tiers": tiers,+ "product_url": get("product_url"),+ "image_url": get("image_url"),+ "flags": flags,+ }+ })+}++fn get_vendor_key(hit: &Value) -> String {+ hit.get("vendor").and_then(|x| x.as_str()).unwrap_or("mouser").to_lowercase()+}++/// Enrich a chip from the Adom parts tools: write info.json.description (++/// manufacturer / image / datasheet / product URLs) and stock.json. Best-effort+/// per field; never fails the sourcing run.+pub fn enrich(mpn: &str) -> Result<EnrichReport> {+ let dir = library::root().join(mpn);+ if !dir.exists() {+ anyhow::bail!("library/{mpn}/ does not exist");+ }+ let data = parts_search(mpn)?;+ let mut seen = std::collections::HashSet::new();+ let variants: Vec<String> = all_hits(&data).iter()+ .filter_map(|h| h.get("mpn").and_then(|x| x.as_str()).map(String::from))+ .filter(|m| !m.is_empty() && seen.insert(m.clone()))+ .collect();+ let hit = best_hit(&data, mpn);++ let mut description = None;+ let mut wrote_stock = false;++ if let Some(h) = hit.as_ref() {+ // --- info.json: description + provenance-friendly metadata ---+ let info_path = dir.join("info.json");+ let mut info: Value = std::fs::read_to_string(&info_path).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| json!({}));+ // Only set the description from the distributor if there isn't already a+ // (likely hand-curated, nicer) one — don't clobber a better description.+ let existing = info.get("description").and_then(|d| d.as_str()).unwrap_or("").trim().to_string();+ if existing.len() < 10 {+ let raw = h.get("description").and_then(|x| x.as_str()).unwrap_or("");+ let cleaned = clean_description(raw);+ if cleaned.len() >= 10 {+ info["description"] = json!(cleaned);+ description = Some(cleaned);+ }+ } else {+ description = Some(existing);+ }+ for (k, src) in [("manufacturer", "manufacturer"), ("image_url", "image_url"),+ ("datasheet_url", "datasheet_url"), ("product_url", "product_url")] {+ if let Some(v) = h.get(src).and_then(|x| x.as_str()) {+ if !v.is_empty() && info.get(k).and_then(|x| x.as_str()).unwrap_or("").is_empty() {+ info[k] = json!(v);+ }+ }+ }+ let _ = std::fs::write(&info_path, serde_json::to_string_pretty(&info).unwrap_or_default());++ // --- stock.json: stock + pricing + lifecycle ---+ let stock = stock_body(h);+ if std::fs::write(dir.join("stock.json"), serde_json::to_string_pretty(&stock).unwrap_or_default()).is_ok() {+ wrote_stock = true;+ }+ }++ Ok(EnrichReport { mpn: mpn.to_string(), description, wrote_stock, variants })+}
src/hints.rs+120−47@@ -1,4 +1,4 @@-// Self-enforcing rails for chip-fetcher: heartbeat the dashboard, check that+// Self-enforcing rails for adom-chip-fetcher: heartbeat the dashboard, check that // state is healthy before the next pup action, and emit `HINT:` lines that // the calling AI reads back into context — so the binary itself becomes the // source of truth for "what to do next" instead of relying on skill prose.@@ -8,12 +8,12 @@ use serde::{Deserialize, Serialize}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -const HEARTBEAT_PATH: &str = "/tmp/chip-fetcher-last-heartbeat.json";+const HEARTBEAT_PATH: &str = "/tmp/adom-chip-fetcher-last-heartbeat.json"; const STALE_AFTER_SECS: u64 = 60; const DEFAULT_PORT: u16 = 8786;-const DASHBOARD_SURFACE_PATH: &str = ".config/chip-fetcher/dashboard.json";+const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json"; -/// Canonical sourcing-ladder priority. Single source of truth — `chip-fetcher+/// Canonical sourcing-ladder priority. Single source of truth — `adom-chip-fetcher /// sources` and the playbook prose both read from here. **Always traverse in /// order; never skip to LCSC.** Direct user rule (2026-05-06): *"after Mouser /// you should do digikey, and then only after that try LCSC. I really want to@@ -28,7 +28,7 @@ pub const SOURCE_LADDER: &[(&str, &str)] = &[ ("lcsc", "datasheet.lcsc.com / lcsc.com — LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."), ]; -/// Where the user wants to see the dashboard. Pup is the default — chip-fetcher+/// Where the user wants to see the dashboard. Pup is the default — adom-chip-fetcher /// needs pup anyway to drive vendor sites, so the dashboard tagging along is /// the lowest-friction path. Hydrogen webview is the alternative for users on /// big monitors who prefer the workspace-integrated panel.@@ -151,10 +151,10 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { println!("OK: heartbeat {mpn} → {stage:?}"); if !dashboard_ok { println!("WARN: dashboard unreachable at {url}. Heartbeat cached locally.");- print_hint("start the dashboard with `chip-fetcher app` so users can see live progress");+ print_hint("start the dashboard with `adom-chip-fetcher app` so users can see live progress"); } - // Auto-scroll the MPN's dashboard card into view in the chip-fetcher pup+ // Auto-scroll the MPN's dashboard card into view in the adom-chip-fetcher pup // session. This is the rule John asked for — the binary enforces "scroll // the active card into focus" automatically rather than relying on the AI // to remember it on every heartbeat. No-op if pup isn't running.@@ -170,7 +170,7 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { // skill file the AI may not have re-read this turn. if was_new { println!();- print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `chip-fetcher sources` for the full priority list."));+ print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `adom-chip-fetcher sources` for the full priority list.")); print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT — surface 'no manufacturer CAD' to user before reaching for it."); } @@ -182,12 +182,12 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { // Auto-record source attempts in info.json.fetched_via_chain. The AI // doesn't have to remember to write the chain; the binary parses the- // stage label and logs it. `chip-fetcher next-source <MPN>` then knows+ // stage label and logs it. `adom-chip-fetcher next-source <MPN>` then knows // what's been tried. if let Some((source, outcome)) = crate::sourcing::detect_source_attempt(stage) { let added = crate::sourcing::append_chain(mpn, source, &outcome, Some(stage)).unwrap_or(false); if added {- println!("OK: chain {} → {} recorded for {mpn} (run `chip-fetcher next-source {mpn}` to see what's next)", source.as_slug(), outcome);+ println!("OK: chain {} → {} recorded for {mpn} (run `adom-chip-fetcher next-source {mpn}` to see what's next)", source.as_slug(), outcome); } } @@ -197,7 +197,67 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { Ok(()) } -/// Track stage-order per MPN in /tmp/chip-fetcher-stage-<MPN>.json. Warn+/// A QUIET heartbeat: refresh the dashboard's activity timestamp + stage only.+/// No stdout, no stub creation, no auto-scroll, no chain/stage bookkeeping.+/// This is what the background ticker fires every few seconds so the "working+/// on" bar stays LIVE during a long blocking op — without spamming the log or+/// re-running the heavy per-heartbeat side-effects.+pub fn heartbeat_quiet(mpn: &str, stage: &str) {+ let hb = Heartbeat { mpn: mpn.to_string(), stage: stage.to_string(), ts: now_secs() };+ if let Ok(body) = serde_json::to_string(&hb) {+ let _ = std::fs::write(HEARTBEAT_PATH, &body);+ let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");+ let _ = Command::new("curl")+ .args(["-fs", "-m", "2", "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &body])+ .output();+ }+}++/// Keeps the dashboard "working on" bar LIVE during a long blocking call (a 40s+/// browser XHR, a slow import). Emits one loud heartbeat up front, then a quiet+/// one every ~6s on a background thread until stopped (or dropped). Without this+/// the bar goes stale after ACTIVITY_STALE_SECS mid-operation and the user sees+/// no live status — exactly John's complaint.+pub struct HeartbeatTicker {+ stop: std::sync::Arc<std::sync::atomic::AtomicBool>,+ handle: Option<std::thread::JoinHandle<()>>,+}++impl HeartbeatTicker {+ pub fn start(mpn: &str, stage: &str) -> Self {+ use std::sync::atomic::Ordering;+ let _ = heartbeat(mpn, stage); // one loud heartbeat (stub card, scroll, log)+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let (mpn, stage, s2) = (mpn.to_string(), stage.to_string(), stop.clone());+ let handle = std::thread::spawn(move || {+ let mut elapsed_ms = 0u64;+ while !s2.load(Ordering::Relaxed) {+ std::thread::sleep(std::time::Duration::from_millis(500));+ elapsed_ms += 500;+ if elapsed_ms >= 6000 {+ elapsed_ms = 0;+ if !s2.load(Ordering::Relaxed) {+ heartbeat_quiet(&mpn, &stage);+ }+ }+ }+ });+ HeartbeatTicker { stop, handle: Some(handle) }+ }++ pub fn stop(mut self) { self.shutdown(); }++ fn shutdown(&mut self) {+ self.stop.store(true, std::sync::atomic::Ordering::Relaxed);+ if let Some(h) = self.handle.take() { let _ = h.join(); }+ }+}++impl Drop for HeartbeatTicker {+ fn drop(&mut self) { self.shutdown(); }+}++/// Track stage-order per MPN in /tmp/adom-chip-fetcher-stage-<MPN>.json. Warn /// when the AI heartbeats a later phase (e.g. 5=importing) without having /// hit the prior phase (3=downloading). Doesn't block — just nudges. fn check_stage_order(mpn: &str, stage: &str) -> Result<()> {@@ -205,7 +265,7 @@ fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { Some(p) => p, None => return Ok(()), };- let path = std::path::PathBuf::from(format!("/tmp/chip-fetcher-stage-{mpn}.json"));+ let path = std::path::PathBuf::from(format!("/tmp/adom-chip-fetcher-stage-{mpn}.json")); // Did we ever record a phase before? Distinguish "no prev recorded" // from "prev was phase 0 (queued)" — both are u8 0 but only one is // a legitimate prior state.@@ -234,7 +294,7 @@ fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { /// Walk the active chip's directory and print explicit `WARN:` + `HINT:` lines /// for each missing core artifact. Also counts how many OTHER chips in the /// library are incomplete so the AI sees the batch picture, not just one chip.-/// Pure check; never errors. The deterministic gate is `chip-fetcher gate` —+/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate` — /// this is the inline version that fires on every heartbeat. fn print_completeness_warnings(mpn: &str) -> Result<()> { let lib = match crate::inventory::scan() {@@ -256,11 +316,11 @@ fn print_completeness_warnings(mpn: &str) -> Result<()> { if !missing.is_empty() { println!(); println!("WARN: card {mpn} is INCOMPLETE — missing: {}", missing.join(", "));- print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `chip-fetcher gate` for full status."));+ print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `adom-chip-fetcher gate` for full status.")); } } if incomplete > 0 {- println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `chip-fetcher gate` to see all).");+ println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `adom-chip-fetcher gate` to see all)."); } Ok(()) }@@ -283,7 +343,7 @@ fn auto_scroll_card(mpn: &str) -> Result<bool> { safe_mpn ); let body = serde_json::json!({- "sessionId": "chip-fetcher-dashboard",+ "sessionId": "adom-chip-fetcher-dashboard", "expr": expr, }).to_string(); let out = Command::new("adom-desktop")@@ -303,9 +363,9 @@ pub fn age_secs(hb: &Heartbeat) -> u64 { /// Make sure the dashboard is up AND visible to the user. Idempotent — /// running this every time is the point. Three checks, each with a fix:-/// 1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `chip-fetcher+/// 1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `adom-chip-fetcher /// serve` detached.-/// 2. Pup window with sessionId "chip-fetcher-dashboard" pointing at the dashboard?+/// 2. Pup window with sessionId "adom-chip-fetcher-dashboard" pointing at the dashboard? /// If not, open one (or open Hydrogen tab, per surface preference). /// 3. (best-effort) Foreground the pup window so the user sees it. ///@@ -316,7 +376,7 @@ pub fn dashboard_ensure(force_open: bool) -> Result<()> { if !dashboard_reachable() { println!("WARN: dashboard server not running; starting it..."); let exe = std::env::current_exe()- .unwrap_or_else(|_| std::path::PathBuf::from("chip-fetcher"));+ .unwrap_or_else(|_| std::path::PathBuf::from("adom-chip-fetcher")); // Detach: own session, stdout/stderr to a log file, parent doesn't wait. let log = std::fs::OpenOptions::new() .create(true).append(true)@@ -369,18 +429,31 @@ fn dashboard_proxy_url() -> String { } fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> {- let already_open = pup_session_open("chip-fetcher-dashboard").unwrap_or(false);+ // Native browser: the dashboard is TAB 1 of the single "adom-chip-fetcher" window+ // (scrape sites are tabs beside it). Never spawn a second window — that's+ // the "why do I have a native browser AND a pup?" bug.+ if crate::pup::is_native() {+ let url = dashboard_proxy_url();+ if !force_open && crate::pup::session_has_url("adom-chip-fetcher", &format!("/proxy/{DEFAULT_PORT}/")) {+ println!("OK: dashboard is tab 1 of the adom-chip-fetcher window (native browser)");+ return Ok(());+ }+ crate::pup::open_window("adom-chip-fetcher", "adom-chip-fetcher", &url)?;+ println!("OK: opened the adom-chip-fetcher dashboard as tab 1 of your native browser window");+ return Ok(());+ }+ let already_open = pup_session_open("adom-chip-fetcher-dashboard").unwrap_or(false); if already_open && !force_open {- println!("OK: dashboard pup window 'chip-fetcher-dashboard' already open");+ println!("OK: dashboard pup window 'adom-chip-fetcher-dashboard' already open"); let _ = Command::new("adom-desktop")- .args(["browser_focus_window", "{\"sessionId\":\"chip-fetcher-dashboard\"}"])+ .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"]) .output(); return Ok(()); } let url = dashboard_proxy_url(); let body = serde_json::json!({- "sessionId": "chip-fetcher-dashboard",- "profile": "chip-fetcher",+ "sessionId": "adom-chip-fetcher-dashboard",+ "profile": "adom-chip-fetcher", "url": url, }).to_string(); let out = Command::new("adom-desktop")@@ -390,7 +463,7 @@ fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> { Ok(o) if o.status.success() => { println!("OK: opened dashboard in pup at {}", dashboard_proxy_url()); let _ = Command::new("adom-desktop")- .args(["browser_focus_window", "{\"sessionId\":\"chip-fetcher-dashboard\"}"])+ .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"]) .output(); Ok(()) }@@ -409,7 +482,7 @@ fn ensure_hydrogen_dashboard_tab(_force_open: bool) -> Result<()> { // adom-cli is the canonical Hydrogen-tab driver. If it's missing, fall // back to pup so the user still sees the dashboard. let status = Command::new("adom-cli")- .args(["workspace", "tab-open", "--url", &url, "--title", "chip-fetcher"])+ .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"]) .status(); match status { Ok(s) if s.success() => {@@ -507,10 +580,10 @@ pub fn selfcheck() -> Result<()> { ), } - match pup_session_open("chip-fetcher") {- Some(true) => println!("OK: pup session 'chip-fetcher' is open"),+ match pup_session_open("adom-chip-fetcher") {+ Some(true) => println!("OK: pup session 'adom-chip-fetcher' is open"), Some(false) => warns.push(- "pup session 'chip-fetcher' is NOT open — fetches need this exact sessionId".into(),+ "pup session 'adom-chip-fetcher' is NOT open — fetches need this exact sessionId".into(), ), None => warns.push("could not query adom-desktop browser_list_windows".into()), }@@ -530,21 +603,21 @@ pub fn selfcheck() -> Result<()> { println!(); if errs.is_empty() && warns.is_empty() {- print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");+ print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action."); } else { if !dashboard_reachable() {- print_hint("start the dashboard FIRST: `chip-fetcher app` (Hydrogen tab) or `chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");+ print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up."); } match last() {- None => print_hint("send your first heartbeat: `chip-fetcher heartbeat <MPN> \"starting fetch\"`"),+ None => print_hint("send your first heartbeat: `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"), Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(- "heartbeat is stale — refresh with `chip-fetcher heartbeat {} \"<current stage>\"`",+ "heartbeat is stale — refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`", hb.mpn )), _ => {} }- if matches!(pup_session_open("chip-fetcher"), Some(false)) {- print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"chip-fetcher\", profile:\"chip-fetcher\"`");+ if matches!(pup_session_open("adom-chip-fetcher"), Some(false)) {+ print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"adom-chip-fetcher\", profile:\"adom-chip-fetcher\"`"); } } @@ -564,28 +637,28 @@ pub fn print_hint(line: &str) { pub fn print_next_after(stage: &str, mpn: Option<&str>) { match (stage, mpn) { ("fetched", Some(m)) => {- print_hint(&format!("heartbeat the result: `chip-fetcher heartbeat {m} imported`"));- print_hint(&format!("verify on disk: `chip-fetcher status {m}`"));+ print_hint(&format!("heartbeat the result: `adom-chip-fetcher heartbeat {m} imported`"));+ print_hint(&format!("verify on disk: `adom-chip-fetcher status {m}`")); } ("imported", Some(m)) => {- print_hint(&format!("heartbeat: `chip-fetcher heartbeat {m} imported`"));- print_hint(&format!("dashboard view: `chip-fetcher status {m}`"));+ print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} imported`"));+ print_hint(&format!("dashboard view: `adom-chip-fetcher status {m}`")); } ("pulled", Some(m)) => {- print_hint(&format!("heartbeat: `chip-fetcher heartbeat {m} \"pulled to incoming\"`"));- print_hint("import next: `chip-fetcher import <path> --mpn <MPN>`");+ print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} \"pulled to incoming\"`"));+ print_hint("import next: `adom-chip-fetcher import <path> --mpn <MPN>`"); } ("listed", _) => {- print_hint("inspect one: `chip-fetcher status <MPN>`");- print_hint("fetch a missing one: `chip-fetcher fetch <MPN>`");+ print_hint("inspect one: `adom-chip-fetcher status <MPN>`");+ print_hint("fetch a missing one: `adom-chip-fetcher fetch <MPN>`"); } ("status", Some(m)) => {- print_hint(&format!("if anything is missing, fetch with `chip-fetcher fetch {m}`"));- print_hint(&format!("heartbeat any user-visible action with `chip-fetcher heartbeat {m} \"<stage>\"`"));+ print_hint(&format!("if anything is missing, fetch with `adom-chip-fetcher fetch {m}`"));+ print_hint(&format!("heartbeat any user-visible action with `adom-chip-fetcher heartbeat {m} \"<stage>\"`")); } ("login", _) => {- print_hint("after sign-in, heartbeat: `chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");- print_hint("verify rails: `chip-fetcher selfcheck`");+ print_hint("after sign-in, heartbeat: `adom-chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");+ print_hint("verify rails: `adom-chip-fetcher selfcheck`"); } _ => {} }
src/import.rs+57−1@@ -152,7 +152,7 @@ fn unzip_and_ingest(zip: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) } fn tempfile_dir() -> Result<PathBuf> {- let base = std::env::temp_dir().join(format!("chip-fetcher-{}", std::process::id()));+ let base = std::env::temp_dir().join(format!("adom-chip-fetcher-{}", std::process::id())); std::fs::create_dir_all(&base)?; Ok(base) }@@ -216,6 +216,49 @@ fn looks_like_mpn(s: &str) -> bool { has_alpha && has_digit && len_ok } +/// Datasheet PDF quality — the programmatic guarantee that we have a REAL+/// datasheet, not a 1-page denial/cover page. Shells to pdfinfo (page count) and+/// pdftotext (pinout text). Both ship in the container.+pub struct PdfQuality {+ pub pages: u32,+ pub has_pinout: bool,+}++pub fn pdf_quality(file: &Path) -> PdfQuality {+ let pages = std::process::Command::new("pdfinfo").arg(file).output().ok()+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .and_then(|s| s.lines().find_map(|l| l.strip_prefix("Pages:").map(|n| n.trim().parse::<u32>().ok())))+ .flatten()+ .unwrap_or(0);+ // Pinout signal: pin-table / pin-configuration / common analog pin names.+ let text = std::process::Command::new("pdftotext").arg(file).arg("-").output().ok()+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .unwrap_or_default()+ .to_lowercase();+ let has_pinout = ["pin functions", "pin configuration", "terminal functions",+ "pinout", "pin assignments", "pin descriptions"]+ .iter().any(|k| text.contains(k))+ || text.matches("pin ").count() >= 8;+ PdfQuality { pages, has_pinout }+}++/// Is this chip's datasheet GOOD ENOUGH for ds2sf to extract from? (multi-page ++/// a pinout). Used by the gate + the fetch flow to guarantee a usable datasheet+/// before — and after — running ds2sf. Returns Err(reason) when it isn't.+pub fn datasheet_ready(pdf: &Path) -> std::result::Result<PdfQuality, String> {+ if !pdf.is_file() {+ return Err("no datasheet PDF present".into());+ }+ let q = pdf_quality(pdf);+ if q.pages < 3 {+ return Err(format!("datasheet is only {} page(s) — a CDN cover/denial page, not the real datasheet", q.pages));+ }+ if !q.has_pinout {+ return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table — ds2sf can't extract a symbol from it", q.pages));+ }+ Ok(q)+}+ /// Magic-byte validation. PDFs and STEPs both have unambiguous magic /// strings — checking the first few bytes is the cheapest defense /// against "naked curl returned a 174 KB Cloudflare denial page that@@ -240,6 +283,19 @@ fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> { if !head.starts_with(b"%PDF") { return Err(anyhow!("not a real PDF (missing %PDF magic): {}", file.display())); }+ // QUALITY GATE (learned the hard way 2026-06-23): %PDF magic alone is+ // NOT enough. A 1-page %PDF is almost always a CDN cover/denial page or+ // a selection guide — NOT the datasheet — and it silently clobbered the+ // real multi-page datasheets, breaking ds2sf. A genuine chip datasheet+ // is many pages. Reject anything < 3 pages outright.+ let q = pdf_quality(file);+ if q.pages > 0 && q.pages < 3 {+ return Err(anyhow!(+ "PDF has only {} page(s) — not a real datasheet (likely a CDN cover/denial/selection page). \+ A genuine chip datasheet is many pages with a pinout. Refusing the import: {}",+ q.pages, file.display()+ ));+ } } "step" | "stp" => { // STEP files start with "ISO-10303-21;" (sometimes preceded by BOM)
src/inventory.rs+41−32@@ -371,43 +371,52 @@ fn source_tags( let thumb_kind = if sor_key == "kicad_sym" { "symbol" } else { "footprint" }; let canon_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.svg")).is_file(); + // Label by WHERE it was downloaded (discovery_source), NOT who authored it+ // (content_origin). Aggregators are never "the manufacturer"; manufacturer-+ // direct names the site. See provenance::source_label.+ let manufacturer = info.get("manufacturer").and_then(|m| m.as_str()).unwrap_or("");+ let canon_disc = provenance+ .get(canonical_file)+ .and_then(|e| e.get("discovery_source").or_else(|| e.get("content_origin")))+ .and_then(|s| s.as_str())+ .or_else(|| info.get("source_of_record").and_then(|s| s.get(sor_key)).and_then(|s| s.as_str()))+ .unwrap_or("vendor")+ .to_string();++ // chip-fetcher's goal: show EVERY library + format we pulled down, named, so+ // the user can compare them and pick. One carat entry PER (source × format):+ // "Component Search Engine - KiCad Library"+ // "Component Search Engine - Altium Library"+ // "Component Search Engine - Fusion 360 Library"+ // "PDF Datasheet from Texas Instruments (ti.com)"+ let src = crate::provenance::source_label(&canon_disc, manufacturer);+ let has = |f: String| dir.join(f).is_file(); let mut tags = Vec::new();+ // KiCad library — the canonical render shown on the tile (current source). if has_canonical {- // origin = content_origin (who made it) → discovery_source (where found)- // → info.json source_of_record → "vendor".- let origin = provenance- .get(canonical_file)- .and_then(|e| e.get("content_origin").or_else(|| e.get("discovery_source")))- .and_then(|s| s.as_str())- .or_else(|| info.get("source_of_record").and_then(|s| s.get(sor_key)).and_then(|s| s.as_str()))- .unwrap_or("vendor");- tags.push(SourceTag { method: origin.to_string(), label: source_label(origin), current: true, thumb: canon_thumb });+ tags.push(SourceTag { method: "kicad".into(), label: format!("{src} - KiCad Library"), current: true, thumb: canon_thumb }); }- // ds2sf datasheet extraction — only list it when it actually produced a- // renderable result (<mpn>-{symbol|footprint}.ds2sf.svg). ds2sf often gets a- // symbol but no footprint (no land-pattern table in the datasheet → no pads),- // and listing an empty ds2sf footprint source is confusing.+ // Altium library (.SchLib has the symbol/device, .PcbLib the footprint).+ // adom-sfconvert turns it into KiCad sym+mod via KiCad 10 (service-kicad),+ // rendered to its own variant thumbnail — so the carat previews the Altium+ // library's OWN geometry.+ let altium = if sor_key == "kicad_sym" { format!("{mpn_prefix}.SchLib") } else { format!("{mpn_prefix}.PcbLib") };+ if has(altium) {+ let altium_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.altium.svg")).is_file();+ tags.push(SourceTag { method: "altium".into(), label: format!("{src} - Altium Library"), current: false, thumb: altium_thumb });+ }+ // Fusion 360 / EAGLE library (.lbr holds both the device + the package).+ // adom-sfconvert turns it into KiCad sym+mod, rendered to its own variant+ // thumbnail — so the carat previews the Fusion library's OWN geometry.+ if has(format!("{mpn_prefix}.lbr")) {+ let fusion_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.fusion.svg")).is_file();+ tags.push(SourceTag { method: "fusion".into(), label: format!("{src} - Fusion 360 Library"), current: false, thumb: fusion_thumb });+ }+ // The manufacturer DATASHEET, extracted by ds2sf — the independent check. let ds2sf_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.ds2sf.svg")).is_file(); if dir.join(ds2sf_file).is_file() && ds2sf_thumb {- tags.push(SourceTag { method: "ds2sf".into(), label: source_label("ds2sf"), current: !has_canonical, thumb: true });- }- // Per-source vendor variants kept by import: <mpn>.<method>.kicad_sym/.kicad_mod.- if let Ok(rd) = std::fs::read_dir(dir) {- for e in rd.flatten() {- let name = e.file_name().to_string_lossy().to_string();- if name == canonical_file || !name.ends_with(&ext_suffix) || !name.starts_with(&format!("{mpn_prefix}.")) {- continue;- }- let method = &name[mpn_prefix.len() + 1..name.len() - ext_suffix.len()];- if method.is_empty() || method.contains('.') || method == "extracted" {- continue;- }- if tags.iter().any(|t| t.method == method) {- continue;- }- let t = dir.join(format!("{mpn_prefix}-{thumb_kind}.{method}.svg")).is_file();- tags.push(SourceTag { method: method.to_string(), label: source_label(method), current: false, thumb: t });- }+ let mfr = crate::provenance::source_label("manufacturer", manufacturer);+ tags.push(SourceTag { method: "ds2sf".into(), label: format!("PDF Datasheet from {mfr}"), current: !has_canonical, thumb: true }); } tags }
src/main.rs+2@@ -1,6 +1,8 @@ mod cli; mod creds;+mod provenance; mod deliver;+mod enrich; mod hints; mod import; mod inventory;
src/projects.rs+1−1@@ -27,7 +27,7 @@ fn now_unix() -> u64 { fn store_path() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());- let dir = PathBuf::from(home).join(".config/chip-fetcher");+ let dir = PathBuf::from(home).join(".config/adom-chip-fetcher"); let _ = std::fs::create_dir_all(&dir); dir.join("projects.json") }
src/provenance.rsadded+73@@ -0,0 +1,73 @@+//! Canonical source-provenance LABELING — the single rule for how every Adom+//! EDA app names where a symbol / footprint / 3D / datasheet actually came from.+//!+//! THE RULE (John, 2026-06-23): label by the DISCOVERY SOURCE — where the file+//! was actually downloaded — NOT by content_origin (who authored the model).+//!+//! - ONLY the maker's OWN website counts as "the manufacturer", and then we+//! NAME the site: "Texas Instruments (ti.com)", "Analog Devices (analog.com)".+//! - The moment the manufacturer hands you off to a 3rd-party library —+//! UltraLibrarian, SnapMagic/SnapEDA, Component Search Engine — that+//! AGGREGATOR is the source. NEVER label it "Manufacturer".+//! - A fuzzy bare "Manufacturer" is misleading and banned.+//!+//! This module is mirrored (same mapping) in adom-symbol, adom-footprint and+//! adom-lbr so the label is identical everywhere. See the `chip-provenance-labeling` skill.++/// The maker's canonical website domain, for "Texas Instruments (ti.com)" labels.+/// Matched case-insensitively on a substring of the manufacturer name.+pub fn mfr_domain(manufacturer: &str) -> Option<&'static str> {+ let m = manufacturer.to_lowercase();+ let table = [+ ("texas instr", "ti.com"), ("analog dev", "analog.com"),+ ("stmicro", "st.com"), ("nxp", "nxp.com"), ("microchip", "microchip.com"),+ ("infineon", "infineon.com"), ("on semi", "onsemi.com"), ("onsemi", "onsemi.com"),+ ("nordic", "nordicsemi.com"), ("espressif", "espressif.com"),+ ("renesas", "renesas.com"), ("diodes inc", "diodes.com"), ("vishay", "vishay.com"),+ ("bosch", "bosch-sensortec.com"), ("maxim", "analog.com"), ("dialog", "renesas.com"),+ ("rohm", "rohm.com"), ("toshiba", "toshiba.com"), ("skyworks", "skyworksinc.com"),+ ("qorvo", "qorvo.com"), ("micron", "micron.com"), ("cypress", "infineon.com"),+ ("silicon lab", "silabs.com"), ("silabs", "silabs.com"), ("lattice", "latticesemi.com"),+ ("winbond", "winbond.com"), ("ti ", "ti.com"),+ ];+ table.iter().find(|(k, _)| m.contains(k)).map(|(_, v)| *v)+}++/// The human label for a source, given the discovery-source slug + the+/// manufacturer name. THIS is the function every app calls.+pub fn source_label(discovery: &str, manufacturer: &str) -> String {+ match discovery.to_lowercase().as_str() {+ // Manufacturer-DIRECT: only when downloaded from the maker's own site.+ "manufacturer" | "mfr" | "vendor" => {+ if let Some(dom) = mfr_domain(manufacturer) {+ if manufacturer.is_empty() { format!("Manufacturer · {dom}") }+ else { format!("{manufacturer} ({dom})") }+ } else if !manufacturer.is_empty() {+ format!("{manufacturer} (direct)")+ } else {+ "Manufacturer (direct site)".to_string()+ }+ }+ // 3rd-party aggregators — NEVER "the manufacturer".+ "cse" | "componentsearch" | "cse_ul" | "samacsys" => "Component Search Engine".into(),+ "snapeda" | "snapmagic" => "SnapEDA".into(),+ "ultralibrarian" | "ul" => "Ultra Librarian".into(),+ "mouser" => "Mouser".into(),+ "digikey" => "DigiKey".into(),+ "arrow" => "Arrow".into(),+ "lcsc" => "LCSC".into(),+ "kicad_community" => "KiCad community".into(),+ other => other.to_string(),+ }+}++/// A short note for the "the manufacturer linked you to it, but it's a 3rd+/// party" middle-ground — appended in tooltips so the user knows the maker+/// pointed here even though the file came off an aggregator.+pub fn linked_from_mfr_note(manufacturer: &str) -> String {+ if manufacturer.is_empty() {+ "the manufacturer's page linked to this 3rd-party library".to_string()+ } else {+ format!("{manufacturer}'s own page links here, but the file is hosted by this 3rd-party library — not downloaded from {manufacturer} directly")+ }+}
src/pup.rs+678−73@@ -1,113 +1,718 @@+//! Browser transport for adom-chip-fetcher.+//!+//! adom-chip-fetcher drives the user's desktop browser to source CAD. There are two+//! transports, and we DEFAULT to the first when it's available:+//!+//! 1. **native-browser** (preferred) — the `adom-browser-extension` bridge+//! (`nbrowser_*` verbs). Drives the user's REAL signed-in Chrome/Edge, so+//! their DigiKey/Mouser/SnapEDA logins, distributor pricing, and captcha+//! human-trust all work. Several sites hard-block Puppeteer (DigiKey is the+//! clearest) but serve the native session normally.+//! 2. **pup** (fallback) — Puppeteer (`browser_*` verbs), a cold profile where+//! we have to recreate auth and can hit bot-walls.+//!+//! ISOLATION RULE (John, 2026-06-23): when using the native browser we open ONE+//! new window for the session and manage every site as a TAB inside it, so we+//! never touch the user's other Chrome/Edge windows. That falls out of the+//! session-keyed pattern: `open_window(session)` makes the dedicated window the+//! first time; every later `open_tab(session, …)` adds a tab to *that* window.+ use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::process::Command;+use std::sync::OnceLock;++/// Resolved transport: which verb family to use and which desktop to target.+struct Transport {+ /// Verb prefix: "nbrowser_" (native) or "browser_" (pup).+ prefix: &'static str,+ /// `--target <name>` to disambiguate when 2+ desktops are connected. `None`+ /// lets adom-desktop auto-route (fine for a single desktop).+ target: Option<String>,+ /// True when we're on the user's real signed-in browser.+ native: bool,+}++static TRANSPORT: OnceLock<Transport> = OnceLock::new();++fn transport() -> &'static Transport {+ TRANSPORT.get_or_init(detect_transport)+}++/// True when sourcing runs in the user's real signed-in browser.+pub fn is_native() -> bool {+ transport().native+} +/// Human label for the active surface (for dashboards / logs).+pub fn surface_label() -> String {+ let t = transport();+ if t.native {+ format!("native browser{}", t.target.as_ref().map(|x| format!(" ({x})")).unwrap_or_default())+ } else {+ "pup (cold profile)".to_string()+ }+}++fn detect_transport() -> Transport {+ // Explicit override: CHIP_FETCHER_BROWSER=native|pup+ match std::env::var("CHIP_FETCHER_BROWSER").ok().as_deref() {+ Some("pup") => return Transport { prefix: "browser_", target: None, native: false },+ Some("native") => {+ let target = find_native().unwrap_or(None);+ return Transport { prefix: "nbrowser_", target, native: true };+ }+ _ => {}+ }+ // Auto: prefer the native browser whenever its bridge is live + drivable.+ match find_native() {+ Some(target) => {+ eprintln!(+ "[adom-chip-fetcher] sourcing in an ISOLATED window of your signed-in browser \+ (native-browser bridge{}). Real logins, your distributor pricing, no bot-walls.",+ target.as_ref().map(|t| format!(", {t}")).unwrap_or_default()+ );+ Transport { prefix: "nbrowser_", target, native: true }+ }+ None => {+ eprintln!(+ "[adom-chip-fetcher] native-browser bridge not found — falling back to pup (cold profile). \+ Install the Adom browser extension for your REAL logins + to skip bot-walls that \+ block headless Chrome (DigiKey blocks it outright): \+ https://wiki.adom.inc/adom/adom-browser-extension"+ );+ Transport { prefix: "browser_", target: None, native: false }+ }+ }+}++/// Probe for a live, drivable `native-browser` bridge.+/// Returns `Some(target)` when found (`target=None` ⇒ no explicit `--target`+/// needed), or `None` when absent. The multi-desktop case is handled by reading+/// the `connected[]` list that `bridge_list` returns on `ambiguous_target`, then+/// probing each desktop by name.+fn find_native() -> Option<Option<String>> {+ let (present, connected) = bridge_probe(None);+ if present {+ return Some(None);+ }+ for name in connected {+ if bridge_probe(Some(&name)).0 {+ return Some(Some(name));+ }+ }+ None+}++/// `(native_browser_running, connected_desktop_names)`. The names are non-empty+/// only when the call came back `ambiguous_target` (2+ desktops). `bridge_list`+/// — unlike `ping` — includes `connected[]` in that error, so we read it here.+fn bridge_probe(target: Option<&str>) -> (bool, Vec<String>) {+ let mut cmd = Command::new("adom-desktop");+ if let Some(t) = target {+ cmd.arg("--target").arg(t);+ }+ cmd.arg("bridge_list");+ let v: Value = match cmd.output() {+ Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or(Value::Null),+ _ => return (false, vec![]),+ };+ let present = v+ .get("bridges")+ .and_then(|b| b.as_array())+ .map(|arr| {+ arr.iter().any(|br| {+ br.get("name").and_then(|n| n.as_str()) == Some("native-browser")+ && br.get("paused").and_then(|p| p.as_bool()) != Some(true)+ && (br.get("status").and_then(|s| s.as_str()) == Some("running")+ || br.get("instanceCount").and_then(|c| c.as_u64()).unwrap_or(0) >= 1)+ })+ })+ .unwrap_or(false);+ let connected = connected_from(&v);+ (present, connected)+}++/// Pull the `connected[]` desktop-name list out of an `ambiguous_target` response.+fn connected_from(v: &Value) -> Vec<String> {+ v.get("connected")+ .and_then(|c| c.as_array())+ .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())+ .unwrap_or_default()+}++// ── public browser ops (transport-agnostic) ─────────────────────────────────++/// Open the session's ISOLATED window at `url` (first call) — every later+/// `open_tab` on the same session lands in this same window. On the native+/// browser we drive the active signed-in profile, so the cold-profile `profile`+/// arg is ignored there. pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> { let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());- call("browser_open_window", &json!({- "sessionId": session_id,- "profile": profile,- "url": url,- }))+ let mut args = json!({ "sessionId": session_id, "url": url });+ if !transport().native {+ args["profile"] = json!(profile);+ }+ call_browser("open_window", &args) } +/// Add a tab to the session's existing isolated window. pub fn open_tab(session_id: &str, url: &str) -> Result<Value> { let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());- call("browser_open_tab", &json!({- "sessionId": session_id,- "url": url,- }))+ call_browser("open_tab", &json!({ "sessionId": session_id, "url": url }))+}++/// True when the session already has a live window (≥1 tab). Used to decide+/// whether to add a tab vs. spawn the window.+pub fn session_live(session_id: &str) -> bool {+ let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {+ Ok(v) => v,+ Err(_) => return false,+ };+ // native: {output:"{tabs:[…]}"}, pup: {tabs:[…]}+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output")+ .and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().map(|a| !a.is_empty())).unwrap_or(false)+}++/// True when any tab in the session's window has a URL containing `needle`.+pub fn session_has_url(session_id: &str, needle: &str) -> bool {+ let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {+ Ok(v) => v,+ Err(_) => return false,+ };+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output")+ .and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().cloned())+ .map(|arr| {+ arr.iter().any(|tab| {+ tab.get("url").and_then(|u| u.as_str()).map(|u| u.contains(needle)).unwrap_or(false)+ })+ })+ .unwrap_or(false)+}++/// Open `url` in the session's ONE isolated window — as a TAB when the window+/// already exists (e.g. the dashboard is tab 1), else create the window. This+/// is the rule for native browsing: every source lands as a tab beside the+/// dashboard, never in a separate window and never touching the user's others.+pub fn open_site(session_id: &str, profile: &str, url: &str) -> Result<Value> {+ // Native work window: dashboard is ALWAYS tab 1; vendor pages are tabs beside it.+ if is_native() && session_id == WORK_SESSION {+ ensure_work_window()?;+ return open_tab(session_id, url);+ }+ if session_live(session_id) {+ open_tab(session_id, url)+ } else {+ open_window(session_id, profile, url)+ }+}++pub fn eval(session_id: &str, expr: &str) -> Result<Value> {+ let v = call_browser("eval", &json!({ "sessionId": session_id, "expr": expr }))?;+ // native returns {output:{result}}, pup returns {result}; unwrap either.+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(parsed) = serde_json::from_str::<Value>(out) {+ return Ok(parsed.get("result").cloned().unwrap_or(parsed));+ }+ }+ Ok(v.get("result").cloned().unwrap_or(v))+}++/// Close the session's isolated window (cleanup after a run). Best-effort.+pub fn close_window(session_id: &str) -> Result<Value> {+ call_browser("close_window", &json!({ "sessionId": session_id }))+}++// ── credential awareness: adom-chip-fetcher reads the browser's saved-login store ──+// across ALL profiles (Chrome + Edge), maps hosts to the sourcing ladder, and+// auto-switches to the profile that's signed in to the vendor it's about to+// scrape — so the whole flow self-drives without asking the user anything.++#[derive(Clone)]+pub struct Cred {+ pub browser: String,+ pub profile: String,+ pub host: String,+ pub username: String,+}++static CREDS: OnceLock<Vec<Cred>> = OnceLock::new();++/// Every saved login the browser knows about (origins + usernames; never+/// passwords). Cached for the run.+pub fn credentials() -> &'static Vec<Cred> {+ CREDS.get_or_init(|| query_credentials().unwrap_or_default())+}++fn query_credentials() -> Result<Vec<Cred>> {+ let v = call_browser("credentials", &json!({}))?;+ let out = v.get("output").and_then(|o| o.as_str())+ .ok_or_else(|| anyhow::anyhow!("nbrowser_credentials: no output"))?;+ let parsed: Value = serde_json::from_str(out)?;+ let arr = parsed.as_array().cloned()+ .or_else(|| parsed.get("credentials").and_then(|c| c.as_array()).cloned())+ .unwrap_or_default();+ Ok(arr.iter().map(|r| Cred {+ browser: r.get("browser").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ profile: r.get("profile").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ host: r.get("host").and_then(|x| x.as_str()).unwrap_or("").to_lowercase(),+ username: r.get("username").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ }).collect())+}++/// Ladder-vendor host fragments → friendly name.+pub const VENDOR_HOSTS: &[(&str, &str)] = &[+ ("ultralibrarian", "UltraLibrarian"),+ ("snapeda", "SnapEDA"), ("snapmagic", "SnapEDA"),+ ("componentsearchengine", "SamacSys"), ("samacsys", "SamacSys"),+ ("mouser", "Mouser"), ("digikey", "DigiKey"),+ ("st.com", "ST"), ("ti.com", "TI"), ("nordicsemi", "Nordic"),+ ("nxp.com", "NXP"), ("microchip", "Microchip"), ("arrow.com", "Arrow"),+];++/// Hosts whose downloads are authenticated by adom-chip-fetcher's OWN credential+/// vault (an `Authorization: Basic` header on the XHR), NOT by the browser's+/// saved cookie session. For these we must NOT switch the active browser to+/// wherever a saved login happens to live — the vault works in any browser, so+/// we stay on the user's preferred browser (Chrome). This is why John saw Edge:+/// his CSE login is saved in Edge, so the auto-pick dragged the whole run there+/// even though CSE never needed that browser login.+pub const VAULT_AUTH_HOSTS: &[&str] = &["componentsearchengine", "samacsys"];++/// The user's preferred browser to drive (John: "drive this in my Chrome").+/// Profile keys are "<browser>:<email>"; we bias profile selection toward this.+pub const PREFERRED_BROWSER: &str = "chrome";++/// If `url`'s host matches a ladder vendor, return the profile (email) that's+/// signed in to it — preferring the profile with the broadest vendor coverage,+/// so multi-vendor runs stay on one rich profile when possible.+pub fn profile_for_url(url: &str) -> Option<String> {+ let lower = url.to_lowercase();+ // Vault-authed source → never switch browser; stay on the preferred one.+ if VAULT_AUTH_HOSTS.iter().any(|h| lower.contains(*h)) {+ return None;+ }+ let frag = VENDOR_HOSTS.iter().map(|(h, _)| *h).find(|h| lower.contains(*h))?;+ let creds = credentials();+ // count each profile's total ladder-vendor coverage (for tie-breaking)+ use std::collections::HashMap;+ let mut coverage: HashMap<&str, usize> = HashMap::new();+ for c in creds {+ if VENDOR_HOSTS.iter().any(|(h, _)| c.host.contains(*h)) {+ *coverage.entry(c.profile.as_str()).or_default() += 1;+ }+ }+ creds.iter()+ .filter(|c| c.host.contains(frag))+ .max_by_key(|c| coverage.get(c.profile.as_str()).copied().unwrap_or(0))+ .map(|c| c.profile.clone())+}++/// Switch the native browser's active profile for the next nav/fetch.+pub fn use_profile(email: &str) -> Result<Value> {+ call_browser("use_profile", &json!({ "profile": email }))+}++// ── one-window scrape: a dedicated scrape TAB inside the "adom-chip-fetcher" window ──+// In native mode every vendor page is driven in ONE tab of the single+// adom-chip-fetcher window (tab 1 = dashboard, never touched). This keeps scraping in+// the same isolated window instead of spawning a second "adom-chip-fetcher-scrape" one.++const WORK_SESSION: &str = "adom-chip-fetcher";++fn scrape_tab_file() -> std::path::PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());+ std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/scrape-tab")+}++fn extract_tab_id(v: &Value) -> Option<String> {+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(p) = serde_json::from_str::<Value>(out) {+ if let Some(id) = p.get("tabId").and_then(|x| x.as_str()) {+ return Some(id.to_string());+ }+ }+ }+ v.get("tabId").and_then(|x| x.as_str()).map(String::from)+}++fn work_tab_ids() -> Vec<String> {+ let v = match call_browser("list_tabs", &json!({ "sessionId": WORK_SESSION })) {+ Ok(v) => v,+ Err(_) => return vec![],+ };+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output").and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().cloned())+ .map(|arr| arr.iter().filter_map(|t| t.get("tabId").and_then(|x| x.as_str().map(String::from))).collect())+ .unwrap_or_default()+}++fn dashboard_url() -> String {+ if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {+ format!("https://{host}/proxy/8786/")+ } else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {+ tmpl.replace("{{port}}", "8786")+ } else {+ "http://127.0.0.1:8786/".to_string()+ }+}++/// John's rule: NEVER open the work window without the adom-chip-fetcher dashboard as+/// the FIRST tab. Every path that adds a vendor/scrape tab calls this first, so+/// the window is always created with the dashboard as tab 1.+pub fn ensure_work_window() -> Result<()> {+ if !is_native() {+ return Ok(());+ }+ if session_has_url(WORK_SESSION, "/proxy/8786/") || session_has_url(WORK_SESSION, ":8786/") {+ return Ok(());+ }+ // Drive the user's PREFERRED browser (Chrome). Switch the bridge's active+ // profile to a Chrome profile before opening, so the work window — and every+ // scrape tab in it — lives in Chrome, not whichever browser happens to hold+ // the most vendor logins.+ prefer_chrome_profile();+ // No dashboard tab present → open the window WITH the dashboard as tab 1.+ open_window(WORK_SESSION, WORK_SESSION, &dashboard_url())?;+ Ok(())+}++/// Switch the bridge's active profile to a Chrome profile if one exists, so the+/// work window opens in Chrome (John's stated preference). No-op if the bridge+/// reports no Chrome profile.+pub fn prefer_chrome_profile() {+ let v = match call_browser("profiles", &json!({})) {+ Ok(v) => v,+ Err(_) => return,+ };+ let out = v.get("output").and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .unwrap_or(v);+ let active = out.get("active").and_then(|a| a.as_str()).unwrap_or("");+ if active.starts_with(PREFERRED_BROWSER) {+ return; // already on Chrome+ }+ if let Some(profs) = out.get("profiles").and_then(|p| p.as_array()) {+ if let Some(chrome) = profs.iter().find(|p| {+ p.get("browser").and_then(|b| b.as_str()) == Some(PREFERRED_BROWSER)+ && p.get("live").and_then(|l| l.as_bool()).unwrap_or(true)+ }) {+ if let Some(key) = chrome.get("profile").and_then(|p| p.as_str()) {+ let _ = use_profile(key);+ eprintln!("[adom-chip-fetcher] driving your Chrome profile '{key}'");+ }+ }+ }+}++/// The dedicated scrape tab in the work window — reuse the stored one if it's+/// still alive, else open a fresh tab (beside the dashboard) and remember it.+pub fn ensure_scrape_tab() -> Result<String> {+ ensure_work_window()?; // dashboard is always tab 1 before any scrape tab+ let alive = work_tab_ids();+ if let Ok(stored) = std::fs::read_to_string(scrape_tab_file()) {+ let id = stored.trim().to_string();+ if !id.is_empty() && alive.contains(&id) {+ return Ok(id);+ }+ }+ let v = call_browser("open_tab", &json!({ "sessionId": WORK_SESSION, "url": "about:blank" }))?;+ let id = extract_tab_id(&v).ok_or_else(|| anyhow::anyhow!("no scrape tabId in {v}"))?;+ if let Some(dir) = scrape_tab_file().parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ let _ = std::fs::write(scrape_tab_file(), &id);+ Ok(id)+}++/// Navigate the dedicated scrape tab (native) or the pup scrape window (fallback).+pub fn scrape_navigate(url: &str) -> Result<Value> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ if is_native() {+ // Self-drive: if this is a known vendor, switch to the profile that's+ // signed in to it before loading the page — no questions asked.+ if let Some(prof) = profile_for_url(&url) {+ if use_profile(&prof).is_ok() {+ eprintln!("[adom-chip-fetcher] using your signed-in profile '{prof}' for this vendor");+ }+ }+ let tab = ensure_scrape_tab()?;+ call_browser("navigate", &json!({ "sessionId": WORK_SESSION, "tabId": tab, "url": url }))+ } else {+ // pup: a separate scrape window is the documented two-window pattern.+ let r = call_browser("navigate", &json!({ "sessionId": "adom-chip-fetcher-scrape", "url": url }));+ if r.is_err() {+ return call_browser("open_window", &json!({ "sessionId": "adom-chip-fetcher-scrape", "profile": "adom-chip-fetcher", "url": url }));+ }+ r+ }+}++pub fn scrape_eval(expr: &str) -> Result<Value> {+ let args = if is_native() {+ let tab = ensure_scrape_tab()?;+ json!({ "sessionId": WORK_SESSION, "tabId": tab, "expr": expr })+ } else {+ json!({ "sessionId": "adom-chip-fetcher-scrape", "expr": expr })+ };+ let v = call_browser("eval", &args)?;+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(parsed) = serde_json::from_str::<Value>(out) {+ return Ok(parsed.get("result").cloned().unwrap_or(parsed));+ }+ }+ Ok(v.get("result").cloned().unwrap_or(v))+}++/// Fetch a URL's BYTES through the user's signed-in browser (real cookies, so+/// login-gated CAD works too) and write them to `dest`. No download, no native+/// viewer, no extra window — the bytes come back base64 in one call. Returns+/// (bytes_written, content_type).+pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, String)> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ let v = call_browser("fetch_url", &json!({ "sessionId": WORK_SESSION, "url": url }))?;+ let out = v.get("output").and_then(|o| o.as_str())+ .ok_or_else(|| anyhow::anyhow!("fetch_url: no output ({v})"))?;+ let o: Value = serde_json::from_str(out)+ .map_err(|e| anyhow::anyhow!("fetch_url: bad json ({e}): {out:.120}"))?;+ if !o.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ anyhow::bail!("fetch_url failed (HTTP {})", o.get("status").and_then(|s| s.as_i64()).unwrap_or(0));+ }+ let body = o.get("body").and_then(|b| b.as_str()).ok_or_else(|| anyhow::anyhow!("fetch_url: no body"))?;+ let ctype = o.get("contentType").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let bytes = b64_decode(body);+ if let Some(dir) = dest.parent() {+ std::fs::create_dir_all(dir).ok();+ }+ std::fs::write(dest, &bytes)?;+ Ok((bytes.len(), ctype))+}++/// Base64-encode (for the CSE basic-auth Authorization header).+fn b64_encode(data: &[u8]) -> String {+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for ch in data.chunks(3) {+ let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(n >> 18 & 63) as usize] as char);+ out.push(T[(n >> 12 & 63) as usize] as char);+ out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });+ out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });+ }+ out+}++/// Fetch a Component Search Engine CAD zip for `mpn` via XHR + basic-auth.+/// XHR (unlike fetch()) accepts an Authorization header AND doesn't trigger a+/// browser download — so this bypasses BOTH the fetch() credential ban and the+/// browser's multiple-download block. The scrape tab is navigated to CSE first+/// (same-origin). Chains entry → partID → zipForm → ga/model.php. Returns the+/// raw zip bytes (`.step` + `.kicad_sym` + `.kicad_mod`).+pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec<u8>> {+ // Navigate CLEAN (no inline-credential wrapping) — auth is the XHR header,+ // and an inline-cred URL breaks the page + leaks the password into the URL bar.+ let tab = ensure_scrape_tab()?;+ call_browser("navigate", &json!({+ "sessionId": WORK_SESSION, "tabId": tab,+ "url": "https://mouser.componentsearchengine.com/"+ }))?;+ std::thread::sleep(std::time::Duration::from_secs(4));+ let auth = b64_encode(format!("{user}:{pass}").as_bytes());+ let mpn_js = serde_json::to_string(mpn).unwrap();+ let mfr_js = serde_json::to_string(mfr).unwrap();+ let expr = format!(+ "(function(){{return new Promise(function(res){{var A='Basic {auth}';var MPN={mpn_js};var MFR={mfr_js};\+ function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);x.setRequestHeader('Authorization',A);\+ if(ty)x.responseType=ty;x.onload=function(){{cb(x)}};x.onerror=function(){{res(JSON.stringify({{err:'xhr'}}))}};x.send()}}\+ var base='https://mouser.componentsearchengine.com/';\+ xhr(base+'entry_u_newDesign.php?mna='+encodeURIComponent(MFR)+'&mpn='+encodeURIComponent(MPN)+'&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB',null,function(a){{\+ var m=(a.responseText.match(/partID=(\\d+)/)||[]);var pid=m[1];if(!pid)return res(JSON.stringify({{err:'no-partID'}}));\+ xhr(base+'preview_newDesign.php?o3=0&partID='+pid+'&ev=0&fmt=zip&pna=Mouser',null,function(b){{\+ var f=new DOMParser().parseFromString(b.responseText,'text/html').getElementById('zipForm');if(!f)return res(JSON.stringify({{err:'no-zipForm'}}));\+ var q=[].slice.call(f.querySelectorAll('input,select')).map(function(i){{return encodeURIComponent(i.name)+'='+encodeURIComponent(i.value)}}).filter(function(s){{return s.length>1}}).join('&');\+ xhr(base+'ga/model.php?'+q,'arraybuffer',function(c){{var u=new Uint8Array(c.response);var s='';\+ for(var i=0;i<u.length;i+=8192)s+=String.fromCharCode.apply(null,u.subarray(i,i+8192));\+ res(JSON.stringify({{head:[u[0],u[1],u[2],u[3]],len:u.length,b64:btoa(s)}}))}})}})}})}})}})()",+ auth = auth, mpn_js = mpn_js, mfr_js = mfr_js+ );+ // Cold CSE calls flake (the entry XHR fires before the session cookie+ // fully settles → spurious "no-partID"). Retry the whole chain a few+ // times, re-warming the page each round, before giving up.+ let mut last_err = String::from("no attempt");+ for attempt in 0..4 {+ if attempt > 0 {+ // re-warm: the homepage navigate primes the session cookie+ let _ = call_browser("navigate", &json!({+ "sessionId": WORK_SESSION, "tabId": tab,+ "url": "https://mouser.componentsearchengine.com/"+ }));+ std::thread::sleep(std::time::Duration::from_secs(3));+ }+ let v = scrape_eval(&expr)?;+ let s = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());+ let r: Value = serde_json::from_str(&s).unwrap_or(v);+ let head: Vec<u64> = r.get("head").and_then(|h| h.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();+ if head == [80, 75, 3, 4] {+ let b64 = r.get("b64").and_then(|b| b.as_str())+ .ok_or_else(|| anyhow::anyhow!("CSE: no zip body"))?;+ return Ok(b64_decode(b64));+ }+ last_err = format!("head {head:?}, err {:?}", r.get("err"));+ }+ anyhow::bail!("CSE returned non-zip after 4 attempts ({last_err})")+}++/// Minimal base64 decoder (the binary has no base64 crate; it has b64 encode in+/// main.rs). Skips whitespace/newlines, stops at padding.+fn b64_decode(s: &str) -> Vec<u8> {+ let mut t = [255u8; 256];+ for (i, c) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".iter().enumerate() {+ t[*c as usize] = i as u8;+ }+ let mut out = Vec::with_capacity(s.len() / 4 * 3);+ let (mut buf, mut bits) = (0u32, 0u32);+ for &c in s.as_bytes() {+ if c == b'=' { break; }+ let v = t[c as usize];+ if v == 255 { continue; }+ buf = (buf << 6) | v as u32;+ bits += 6;+ if bits >= 8 {+ bits -= 8;+ out.push((buf >> bits) as u8);+ }+ }+ out+}++pub fn scrape_screenshot() -> Result<Value> {+ if is_native() {+ let tab = ensure_scrape_tab()?;+ call_browser("screenshot", &json!({ "sessionId": WORK_SESSION, "tabId": tab }))+ } else {+ call_browser("screenshot", &json!({ "sessionId": "adom-chip-fetcher-scrape" }))+ } } /// Pull a file from the user's desktop into a local container directory.-/// Wraps `adom-desktop pull_file`. Returns the local path of the saved file.+/// Desktop-level verb (not browser-scoped) — same on both transports. pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> {- let v = call("pull_file", &json!({- "filePaths": [remote_path],- "saveTo": save_to,- }))?;+ let v = call_desktop("pull_file", &json!({ "filePaths": [remote_path], "saveTo": save_to }))?; let local = v["files"][0]["path"] .as_str() .ok_or_else(|| anyhow::anyhow!("pull_file returned no path: {v}"))?; Ok(std::path::PathBuf::from(local)) } -/// Block until a file matching `glob` lands in `path` on the user's desktop,-/// then return the parsed JSON result. Uses adom-desktop v1.5+'s native-/// `desktop_watch_files` — no shell, no user approval. `since` defaults to-/// "now" inside the bridge, so call this BEFORE the click that triggers the-/// download.+/// Block until a file matching `glob` lands in `path` on the user's desktop. pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Value> {- call("desktop_watch_files", &json!({- "path": path,- "glob": glob,- "timeoutMs": timeout_ms,- }))+ call_desktop(+ "desktop_watch_files",+ &json!({ "path": path, "glob": glob, "timeoutMs": timeout_ms }),+ ) } -pub fn eval(session_id: &str, expr: &str) -> Result<Value> {- let v = call("browser_eval", &json!({- "sessionId": session_id,- "expr": expr,- }))?;- Ok(v.get("result").cloned().unwrap_or(v))+// ── call plumbing ────────────────────────────────────────────────────────────++/// Call a browser verb — prefixes with the active transport's verb family and+/// injects `--target` when one is resolved.+fn call_browser(suffix: &str, args: &Value) -> Result<Value> {+ let t = transport();+ let verb = format!("{}{}", t.prefix, suffix);+ call_with_recovery(&verb, args, t.target.as_deref(), true) } -fn call(action: &str, args: &Value) -> Result<Value> {- call_with_recovery(action, args, true)+/// Call a desktop-level verb (no transport prefix).+fn call_desktop(action: &str, args: &Value) -> Result<Value> {+ call_with_recovery(action, args, transport().target.as_deref(), true) } -/// Run `adom-desktop <action> <args>` and parse the JSON result.+/// Run `adom-desktop [--target T] <action> <args>` and parse the JSON result. ///-/// If adom-desktop v1.5.1+ reports `errorCode: session_not_found` or-/// `session_disconnected` AND `allow_recovery` is true, we transparently:-/// 1. Call `browser_rescan` (which walks every Chrome target and rebuilds-/// the bridge's session map by parsing `(session: X)` page-title tags).-/// 2. If the target session reappears, retry the original call ONCE.-/// 3. If still missing, return the original error so the caller can decide-/// whether to spawn a fresh window.-///-/// This recovery path was added because pre-v1.5.1 the bridge would lose-/// sessions on a transient WebSocket drop and silently retarget unrelated-/// windows. v1.5.1 surfaces a clear error code instead — and we use it.-fn call_with_recovery(action: &str, args: &Value, allow_recovery: bool) -> Result<Value> {- let out = Command::new("adom-desktop")- .arg(action)- .arg(args.to_string())+/// Recovers from two transient conditions:+/// - `session_not_found` / `session_disconnected`: rescan + retry once.+/// - `ambiguous_target` (2+ desktops, no target passed): discover the native+/// target (or first connected) and retry once with `--target`.+fn call_with_recovery(+ action: &str,+ args: &Value,+ target: Option<&str>,+ allow_recovery: bool,+) -> Result<Value> {+ let mut cmd = Command::new("adom-desktop");+ if let Some(t) = target {+ cmd.arg("--target").arg(t);+ }+ cmd.arg(action).arg(args.to_string());+ let out = cmd .output() .with_context(|| format!("running adom-desktop {action}"))?; if !out.status.success() {- anyhow::bail!("adom-desktop {action} failed: {}", String::from_utf8_lossy(&out.stderr));+ anyhow::bail!(+ "adom-desktop {action} failed: {}",+ String::from_utf8_lossy(&out.stderr)+ ); } let v: Value = serde_json::from_slice(&out.stdout) .with_context(|| format!("parsing adom-desktop {action} stdout"))?; - // Recovery recipe per adom-desktop v1.5.1: errorCode tells us when the- // bridge lost the session. Try one rescan + retry before giving up.- if allow_recovery {- if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {- if code == "session_not_found" || code == "session_disconnected" {- let target = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");- println!("WARN: adom-desktop {action} returned {code} for session {target:?}; running browser_rescan");- let rescan = Command::new("adom-desktop")- .args(["browser_rescan", "{}"])- .output();- let live = rescan.ok().and_then(|o| {- if !o.status.success() { return None; }- let r: Value = serde_json::from_slice(&o.stdout).ok()?;- r.get("liveSessions")- .and_then(|l| l.as_array())- .map(|arr| arr.iter()- .filter_map(|v| v.as_str().map(String::from))- .collect::<Vec<_>>())- });- let live = live.unwrap_or_default();- if !target.is_empty() && live.iter().any(|s| s == target) {- println!("OK: rescan recovered session {target:?} (live now: {live:?}); retrying {action}");- return call_with_recovery(action, args, false);+ if !allow_recovery {+ return Ok(v);+ }++ // Recovery 1: ambiguous target — pick a concrete desktop and retry.+ if target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {+ let pick = find_native()+ .and_then(|t| t)+ .or_else(|| connected_from(&v).into_iter().next());+ if let Some(name) = pick {+ println!("WARN: adom-desktop {action} ambiguous_target; retrying on --target {name}");+ return call_with_recovery(action, args, Some(&name), false);+ }+ }++ // Recovery 2: session lost — rescan, then retry once if it came back.+ if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {+ if code == "session_not_found" || code == "session_disconnected" {+ let sess = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");+ println!("WARN: adom-desktop {action} returned {code} for session {sess:?}; running rescan");+ let prefix = transport().prefix;+ let mut rescan = Command::new("adom-desktop");+ if let Some(t) = target {+ rescan.arg("--target").arg(t);+ }+ let rescan = rescan.args([&format!("{prefix}rescan"), "{}"]).output();+ let live = rescan.ok().and_then(|o| {+ if !o.status.success() {+ return None; }- println!("WARN: rescan did not recover {target:?} (live: {live:?}); returning original error");+ let r: Value = serde_json::from_slice(&o.stdout).ok()?;+ r.get("liveSessions").and_then(|l| l.as_array()).map(|arr| {+ arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()+ })+ });+ let live = live.unwrap_or_default();+ if !sess.is_empty() && live.iter().any(|s| s == sess) {+ println!("OK: rescan recovered session {sess:?}; retrying {action}");+ return call_with_recovery(action, args, target, false); }+ println!("WARN: rescan did not recover {sess:?} (live: {live:?})"); } } Ok(v)
src/serve.rs+268−4@@ -24,8 +24,8 @@ fn now_epoch() -> u64 { pub fn run(port: u16) -> Result<()> { let addr = format!("0.0.0.0:{port}"); let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;- println!("chip-fetcher viewer listening on http://{addr}");- println!("(open in Hydrogen via `chip-fetcher app`, or visit in any browser)");+ println!("adom-chip-fetcher viewer listening on http://{addr}");+ println!("(open in Hydrogen via `adom-chip-fetcher app`, or visit in any browser)"); for mut request in server.incoming_requests() { let raw_url = request.url().to_string();@@ -40,6 +40,8 @@ pub fn run(port: u16) -> Result<()> { let response = match (&method, url.as_str()) { (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML), (Method::Get, "/api/library") => library_json(),+ (Method::Get, "/api/sources") => sources_json(),+ (Method::Get, "/api/validate") => validate_json(), (Method::Get, "/api/projects") => projects_json(), (Method::Get, "/api/activity") => activity_get(), (Method::Post, "/api/activity") => {@@ -793,6 +795,265 @@ fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(body).with_header(h) } +/// Brutal self-audit: for every chip (the active project if set), report ✓/✗ for+/// EVERY expected artifact — datasheet, 3D STEP, KiCad symbol+footprint, the card+/// thumbnails (symbol.svg / footprint.svg / 3d-iso.png), the Fusion/EAGLE .lbr,+/// and info.json. A chip is "complete" ONLY if every REQUIRED box is green. This+/// is the tab that stops anyone (human or AI) from calling it done when it isn't.+fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> {+ use serde_json::json;+ let root = crate::library::root();+ let active = crate::projects::get_active();+ let project_mpns: Option<Vec<String>> = active.as_ref().map(|p| crate::projects::mpns_for_project(p));++ let valid_pdf = |p: std::path::PathBuf| -> bool {+ std::fs::File::open(&p).ok().and_then(|mut f| {+ use std::io::Read;+ let mut b = [0u8; 4];+ f.read_exact(&mut b).ok().map(|_| &b == b"%PDF")+ }).unwrap_or(false)+ };+ let valid_step = |p: std::path::PathBuf| -> bool {+ std::fs::read_to_string(&p).ok().map(|s| s.contains("ISO-10303")).unwrap_or(false)+ };+ let nonempty = |p: std::path::PathBuf, min: u64| -> bool {+ std::fs::metadata(&p).map(|m| m.len() >= min).unwrap_or(false)+ };++ let mut chips = Vec::new();+ let (mut total, mut complete) = (0u32, 0u32);+ if let Ok(rd) = std::fs::read_dir(&root) {+ let mut dirs: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).collect();+ dirs.sort_by_key(|e| e.file_name());+ for e in dirs {+ let mpn = e.file_name().to_string_lossy().to_string();+ if let Some(ref pm) = project_mpns {+ if !pm.iter().any(|m| m == &mpn) { continue; }+ }+ let d = e.path();+ // REQUIRED — files a complete card MUST have+ let pdf = valid_pdf(d.join(format!("{mpn}.pdf")));+ let step = valid_step(d.join(format!("{mpn}.step")));+ let sym = nonempty(d.join(format!("{mpn}.kicad_sym")), 50);+ let foot = nonempty(d.join(format!("{mpn}.kicad_mod")), 50);+ let sym_svg = nonempty(d.join(format!("{mpn}-symbol.svg")), 500);+ let fp_svg = nonempty(d.join(format!("{mpn}-footprint.svg")), 500);+ let thumb3d = nonempty(d.join(format!("{mpn}-3d-iso.png")), 2000);+ // EXTRA — nice-to-have+ let lbr = nonempty(d.join(format!("{mpn}.lbr")), 50);+ let info = d.join("info.json").exists();+ // Description: the human "what is this chip" line shown on the card ++ // zoom. Tracked so a blank one is impossible to miss in the audit.+ let has_desc = std::fs::read_to_string(d.join("info.json")).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("description").and_then(|x| x.as_str()).map(|s| s.trim().len() >= 10))+ .unwrap_or(false);++ // adom-concur cross-source consensus: did the KiCad / EAGLE / Altium+ // / ds2sf variants of this part AGREE? Surface concur's verdict + the+ // per-axis agree/disagree tally so the audit shows consistency, not+ // just file presence.+ let consensus = std::fs::read_to_string(d.join(format!("{mpn}-concur.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .map(|c| {+ let axes = c.get("axes").and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let count = |v: &str| axes.iter().filter(|a| a.get("verdict").and_then(|x| x.as_str()) == Some(v)).count();+ json!({+ "status": c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),+ "sources": c.get("sources_compared").cloned().unwrap_or(json!([])),+ "missing": c.get("sources_missing").cloned().unwrap_or(json!([])),+ "agree": count("agree"), "disagree": count("disagree"), "total_axes": axes.len()+ })+ });++ let required = [pdf, step, sym, foot, sym_svg, fp_svg, thumb3d];+ let all = required.iter().all(|&b| b);+ total += 1;+ if all { complete += 1; }+ chips.push(json!({+ "mpn": mpn,+ "complete": all,+ "required": {+ "datasheet_pdf": pdf, "step_3d": step,+ "kicad_symbol": sym, "kicad_footprint": foot,+ "thumb_symbol": sym_svg, "thumb_footprint": fp_svg, "thumb_3d": thumb3d+ },+ "extra": { "description": has_desc, "fusion_lbr": lbr, "info_json": info },+ "consensus": consensus,+ "sources": per_source_breakdown(&d, &mpn, step)+ }));+ }+ }+ let body = json!({+ "project": active,+ "summary": { "total": total, "complete": complete, "incomplete": total - complete },+ "required_keys": ["datasheet_pdf","step_3d","kicad_symbol","kicad_footprint","thumb_symbol","thumb_footprint","thumb_3d"],+ "extra_keys": ["description","fusion_lbr","info_json"],+ "chips": chips+ }).to_string();+ json_response(body)+}++/// Per-source-library breakdown for the Validation tab's child rows: for each+/// library we downloaded (CSE / UltraLibrarian / SnapEDA / ds2sf), which of+/// symbol / footprint / 3D did we generate from IT. 3D is special: a library+/// that shipped its OWN STEP (`<mpn>.<method>.step`) gets a normal check; one+/// that didn't gets a check with an asterisk (we used the global chip STEP).+fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) -> serde_json::Value {+ use serde_json::json;+ let has = |f: String| d.join(f).is_file();++ // The DISCOVERY source (where the CAD was found) labels the format lines —+ // CSE / SnapEDA / UltraLibrarian — NOT the content_origin ("manufacturer"),+ // because CSE is an aggregator, not the maker. Read it from file-provenance.+ let prov = std::fs::read_to_string(d.join(format!("{mpn}-file-provenance.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let disc = prov.as_ref().and_then(|p| p.as_object())+ .and_then(|m| m.values().find_map(|e| e.get("discovery_source").and_then(|s| s.as_str())))+ .unwrap_or("cse");+ // Manufacturer name (for naming the maker's own site when it's truly direct).+ let manufacturer = std::fs::read_to_string(d.join("info.json")).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("manufacturer").and_then(|m| m.as_str()).map(String::from))+ .unwrap_or_default();+ // PROVENANCE LABELING RULE (John, 2026-06-23): label by WHERE it was actually+ // downloaded, never the content_origin. ONLY the maker's OWN site counts as+ // "the manufacturer" — and then NAME the site (ti.com / analog.com / st.com).+ // The moment they hand off to UltraLibrarian / SnapMagic / Component Search+ // Engine, that aggregator is the source — NOT the manufacturer.+ let src_label = |disc: &str| crate::provenance::source_label(disc, &manufacturer);+ let src = src_label(disc);++ // Where the GLOBAL chip STEP came from — for the asterisk tooltip on lines+ // whose own library had no 3D ("we swapped in the STEP we got from <X>").+ let step_e = prov.as_ref().and_then(|p| p.get(format!("{mpn}.step")));+ let step_disc = step_e.and_then(|e| e.get("discovery_source")).and_then(|s| s.as_str()).unwrap_or(disc);+ let step_source = src_label(step_disc);+ let step_url = step_e.and_then(|e| e.get("url")).and_then(|s| s.as_str())+ .filter(|s| !s.is_empty()).map(String::from);++ // Download link for a file (relative path under /asset/<mpn>/). UI prepends+ // API_BASE. `original=true` = the raw archive/file we pulled off the internet+ // (for the user to inspect); `false` = a specific file we extracted from it.+ let dl = |name: &str, file: String, original: bool| json!({ "name": name, "file": file, "original": original });+ // The original CSE bundle zip (the exact bytes downloaded) — shared by every+ // EDA-format line, since CSE ships one archive with all formats inside.+ let cse_zip = has(format!("{mpn}.cse-bundle.zip"));++ let mut lines: Vec<serde_json::Value> = Vec::new();++ // 1) Manufacturer DATASHEET → ds2sf extraction is its OWN source. The+ // datasheet has no 3D, so its 3D cell is the global chip STEP (asterisk).+ let ds_tried = has(format!("{mpn}-extraction.result.json"));+ let ds_sym = has(format!("{mpn}-symbol.extracted.json")) || has(format!("{mpn}-symbol.ds2sf.svg"));+ let ds_fp = has(format!("{mpn}-footprint.extracted.json")) || has(format!("{mpn}-footprint.ds2sf.svg"));+ if ds_tried || ds_sym || ds_fp {+ let mut files = vec![];+ // The datasheet PDF IS the original download for this line.+ if has(format!("{mpn}.pdf")) { files.push(dl("datasheet (PDF)", format!("{mpn}.pdf"), true)); }+ // Why is ds2sf red? Surface the extractor's own reason so the ✗ explains itself.+ let note = std::fs::read_to_string(d.join(format!("{mpn}-extraction.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|r| {+ let status = r.get("status").and_then(|s| s.as_str()).unwrap_or("");+ let summary = r.get("summary").and_then(|s| s.as_str()).unwrap_or("");+ if status == "ok" { None } else {+ Some(format!("ds2sf could not extract (status: {status}). {summary}"))+ }+ });+ // The datasheet is fetched from the maker's own lit server → name the site.+ lines.push(json!({ "label": format!("PDF Datasheet from {}", src_label("manufacturer")),+ "sym": ds_sym, "fp": ds_fp, "three_d": has_global_step, "three_d_from_lib": false,+ "step_source": step_source, "step_url": step_url, "files": files, "note": note }));+ }++ // 2) One line PER EDA FORMAT in the downloaded bundle — each library has its+ // own symbol + footprint, and (for CSE/UL/SnapEDA bundles) its own STEP.+ // Each line carries download links to the EXACT files we used.+ let three_lib = json!(has_global_step); // CSE/UL/SnapEDA bundles ship the STEP with each format+ let orig_zip = |files: &mut Vec<serde_json::Value>| {+ if cse_zip { files.push(dl("original bundle (.zip)", format!("{mpn}.cse-bundle.zip"), true)); }+ };+ let ki = (has(format!("{mpn}.kicad_sym")), has(format!("{mpn}.kicad_mod")));+ if ki.0 || ki.1 {+ let mut files = vec![];+ if ki.0 { files.push(dl(".kicad_sym", format!("{mpn}.kicad_sym"), false)); }+ if ki.1 { files.push(dl(".kicad_mod", format!("{mpn}.kicad_mod"), false)); }+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - KiCad Library"), "sym": ki.0, "fp": ki.1,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ let al = (has(format!("{mpn}.SchLib")), has(format!("{mpn}.PcbLib")));+ if al.0 || al.1 {+ let mut files = vec![];+ if al.0 { files.push(dl(".SchLib", format!("{mpn}.SchLib"), false)); }+ if al.1 { files.push(dl(".PcbLib", format!("{mpn}.PcbLib"), false)); }+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - Altium Library"), "sym": al.0, "fp": al.1,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ // EAGLE/Fusion .lbr is a single file holding BOTH the device (symbol) + package (footprint).+ if has(format!("{mpn}.lbr")) {+ let mut files = vec![dl(".lbr", format!("{mpn}.lbr"), false)];+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - Fusion 360 Library"), "sym": true, "fp": true,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ json!(lines)+}++/// The CANONICAL sourcing-priority — the single source of truth for "what order+/// chip-fetcher searches the entire internet for electronics data." The+/// dashboard's Sourcing tab renders this verbatim, and the AI must follow it.+/// Three concerns, each with its own ordered ladder. ALWAYS manufacturer-first+/// for CAD; ALWAYS the Adom data tools (keys baked in) for metadata; the manual+/// CAD-crawl skills are the FALLBACK, in order, with LCSC dead last.+fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> {+ use serde_json::json;+ let body = json!({+ "concerns": [+ {+ "concern": "1 · Identity & metadata",+ "what": "What the part IS — description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",+ "rule": "ALWAYS query the Adom data tools first — their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.",+ "steps": [+ {"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella — searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"},+ {"tier": "Adom tool", "name": "adom-mouser", "detail": "Single-vendor deep lookup + richest stock/lifecycle. Backend keeps the key server-side (`adom-mouser serve`).", "cmd": "adom-mouser part <MPN>"},+ {"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog — broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},+ {"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog — assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"}+ ]+ },+ {+ "concern": "2 · Datasheet (PDF)",+ "what": "The manufacturer datasheet.",+ "rule": "Manufacturer's own lit server first; Adom tools give the fallback link.",+ "steps": [+ {"tier": "Manufacturer", "name": "Manufacturer lit server", "detail": "e.g. ti.com/lit/ds/symlink/<part>.pdf, st.com, nxp.com. ALWAYS try first.", "cmd": "chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>"},+ {"tier": "Adom tool", "name": "adom-parts-search datasheet_url", "detail": "Mouser/DigiKey expose a datasheet link when the mfr URL is unknown.", "cmd": "adom-parts-search search <MPN>"}+ ]+ },+ {+ "concern": "3 · CAD bundle (STEP + symbol + footprint)",+ "what": "The 3D STEP model + KiCad/Altium/Fusion symbol & footprint.",+ "rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER — never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.",+ "steps": [+ {"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab — the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"},+ {"tier": "Aggregator skill", "name": "SnapMagic / SnapEDA", "detail": "Free CAD bundles, login-gated. Right after the manufacturer.", "skill": "playbooks/snapmagic.md"},+ {"tier": "Aggregator skill", "name": "Mouser CSE (Component Search Engine / SamacSys)", "detail": "Mouser's ECAD Model / Library Loader. Same-origin XHR + Basic-auth; needs the ORDERABLE MPN (from step 1).", "cmd": "chip-fetcher cse <MPN> --mfr <name>", "skill": "playbooks/mouser-cse.md"},+ {"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page — mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"},+ {"tier": "Aggregator skill", "name": "Arrow", "detail": "Niche distributor CAD.", "skill": "playbooks/sourcing-ladder.md"},+ {"tier": "Aggregator skill", "name": "UltraLibrarian / Component Search Engine", "detail": "Only when the manufacturer links there explicitly. Captcha or UL-Pro login flows.", "skill": "playbooks/ultra-librarian.md"},+ {"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com — LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"}+ ]+ }+ ]+ }).to_string();+ json_response(body)+}+ fn library_json() -> Response<std::io::Cursor<Vec<u8>>> { match crate::inventory::scan() { Ok(lib) => json_response(serde_json::to_string(&lib).unwrap_or_default()),@@ -927,7 +1188,10 @@ fn spawn_subprocess_action( mpn: &str, ) -> Response<std::io::Cursor<Vec<u8>>> { use std::process::{Command, Stdio};- let mut cmd = Command::new("chip-fetcher");+ // Self-invoke by absolute path — name-independent so the rename to+ // adom-chip-fetcher (or any future rename) can't break it.+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());+ let mut cmd = Command::new(exe); cmd.args(args) .stdout(Stdio::null()) .stderr(Stdio::null())@@ -948,7 +1212,7 @@ pub fn open_in_hydrogen(port: u16) -> Result<()> { let url = format!("http://127.0.0.1:{port}"); println!("opening {url} in Hydrogen webview"); let status = std::process::Command::new("adom-cli")- .args(["workspace", "tab-open", "--url", &url, "--title", "chip-fetcher"])+ .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"]) .status() .context("running adom-cli workspace tab-open")?; if !status.success() {
src/sources/mod.rsadded+1@@ -0,0 +1 @@+pub mod ti;
src/sources/ti.rsadded+43@@ -0,0 +1,43 @@+pub fn base_part(mpn: &str) -> String {+ let s = mpn.trim_end_matches(|c: char| c == 'R' || c == 'T');+ let chars: Vec<char> = s.chars().collect();+ let n = chars.len();+ let mut i = n;+ while i > 0 && chars[i - 1].is_ascii_digit() { i -= 1; }+ let after_digits = i;+ while i > 0 && chars[i - 1].is_ascii_alphabetic() { i -= 1; }+ let after_alpha = i;+ if after_alpha < after_digits && after_alpha > 0 && chars[after_alpha - 1].is_ascii_digit() {+ chars[..after_alpha].iter().collect()+ } else {+ s.to_string()+ }+}++pub fn product_url(mpn: &str) -> String {+ format!("https://www.ti.com/product/{}", base_part(mpn))+}++/// Land on the TI product page anchored to the CAD section.+/// User clicks "CAD/CAE Symbols & Footprints" → UL opens with the right part pre-loaded.+/// TI.com's part picker is much better than UL's search.+pub fn cad_url(mpn: &str) -> String {+ format!("https://www.ti.com/product/{}#cad-cae-symbols", base_part(mpn))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn strips_package_and_reel() {+ assert_eq!(base_part("TPS62840DLCR"), "TPS62840");+ assert_eq!(base_part("INA226AIDGSR"), "INA226");+ assert_eq!(base_part("LM358DR"), "LM358");+ assert_eq!(base_part("LM358"), "LM358");+ assert_eq!(base_part("MSP430G2553IPW20"), "MSP430G2553");+ assert_eq!(base_part("DRV8825PWPR"), "DRV8825");+ assert_eq!(base_part("ADS1115IDGSR"), "ADS1115");+ assert_eq!(base_part("ADS1115"), "ADS1115");+ }+}
src/thumbnails.rs+87−8@@ -20,7 +20,7 @@ use std::process::Command; use crate::library; -const CHIP_THUMBNAILER_BIN: &str = "chip-thumbnailer";+const CHIP_THUMBNAILER_BIN: &str = "adom-chip-thumbnailer"; const PENDING_MARKER: &str = ".thumb-pending"; pub struct GenReport {@@ -105,13 +105,6 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { // (it predates a working service-kicad path), which made the vendor tile show // "ds2sf SYNTHESIZED". Re-render the canonical tiles from the real vendor // files via adom-symbol/adom-footprint (service-kicad), overwriting that.- let canon_sym = dir.join(format!("{mpn}.kicad_sym"));- if canon_sym.is_file() {- let _ = Command::new("adom-symbol")- .args(["render", "--file"]).arg(&canon_sym)- .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")))- .output();- } let canon_mod = dir.join(format!("{mpn}.kicad_mod")); if canon_mod.is_file() { let _ = Command::new("adom-footprint")@@ -172,6 +165,33 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { generate_named_assets(dir, mpn, &axis); } + // Canonical SYMBOL render — done LAST, AFTER the transparent iso icon exists.+ // John's rule: show adom-symbol's DEFAULT symbol with the iso 3D chip+ // composited into the body. The order matters: rendering the symbol BEFORE+ // the `-3d-iso-icon.png` (transparent) is generated bakes in the shaded+ // `-3d-iso.png` instead — a gray box behind the chip. Generating the icon+ // first (above) guarantees the symbol gets the clean transparent overlay.+ let canon_sym = dir.join(format!("{mpn}.kicad_sym"));+ if canon_sym.is_file() {+ let mut cmd = Command::new("adom-symbol");+ cmd.args(["render", "--file"]).arg(&canon_sym)+ .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")));+ let iso_icon = dir.join(format!("{mpn}-3d-iso-icon.png"));+ let iso_plain = dir.join(format!("{mpn}-3d-iso.png"));+ if iso_icon.is_file() {+ cmd.arg("--iso").arg(&iso_icon);+ } else if iso_plain.is_file() {+ cmd.arg("--iso").arg(&iso_plain);+ }+ let _ = cmd.output();+ }++ // Foreign-format libraries (EAGLE/Fusion .lbr) → convert to KiCad sym+mod via+ // adom-sfconvert so each format renders its OWN geometry in the carat instead+ // of falling back to the canonical KiCad render. Altium .SchLib/.PcbLib are+ // OLE binary — adom-sfconvert errors honestly there, so no Altium thumb yet.+ convert_foreign_libraries(dir, mpn);+ // Per-source vendor variants kept by import (<mpn>.<method>.kicad_sym/.kicad_mod) // → render each to its own thumbnail so the carat shows a preview per source. if let Ok(rd) = std::fs::read_dir(dir) {@@ -196,6 +216,65 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { } } +/// Convert foreign-format vendor libraries into KiCad sym+mod so each format+/// renders its OWN geometry. Writes `<mpn>.<method>.kicad_sym/.kicad_mod`, which+/// the per-source variant loop above then renders to `<mpn>-<kind>.<method>.svg`.+/// Idempotent + best-effort: if the converted files already exist, or the+/// converter can't handle the format (Altium OLE binary), we leave it alone.+fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) {+ // EAGLE / Fusion 360 .lbr → NATIVE via adom-sfconvert (eagle.rs, pure Rust —+ // no service round-trip). Emits export .kicad_sym/.kicad_mod AND the native+ // SVG thumbnails (<mpn>.fusion-symbol.svg / <mpn>.fusion-footprint.svg),+ // which we move to the canonical per-source names so the variant loop skips+ // the service render.+ let lbr = dir.join(format!("{mpn}.lbr"));+ if lbr.is_file() && !dir.join(format!("{mpn}-symbol.fusion.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&lbr)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.fusion"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.fusion-symbol.svg")),+ dir.join(format!("{mpn}-symbol.fusion.svg")),+ );+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.fusion-footprint.svg")),+ dir.join(format!("{mpn}-footprint.fusion.svg")),+ );+ }+ // Altium .SchLib (symbol) + .PcbLib (footprint) → NATIVE via adom-sfconvert+ // (altium-schlib / altium-pcblib, pure Rust — no service round-trip). It+ // emits the export .kicad_sym/.kicad_mod AND the native SVG thumbnail+ // (<mpn>.altium-symbol.svg / <mpn>.altium-footprint.svg). We move those SVGs+ // to chip-fetcher's canonical per-source thumbnail names so the variant+ // render loop below finds them already present and skips the service render.+ let schlib = dir.join(format!("{mpn}.SchLib"));+ if schlib.is_file() && !dir.join(format!("{mpn}-symbol.altium.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&schlib)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.altium"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.altium-symbol.svg")),+ dir.join(format!("{mpn}-symbol.altium.svg")),+ );+ }+ let pcblib = dir.join(format!("{mpn}.PcbLib"));+ if pcblib.is_file() && !dir.join(format!("{mpn}-footprint.altium.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&pcblib)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.altium"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.altium-footprint.svg")),+ dir.join(format!("{mpn}-footprint.altium.svg")),+ );+ }+}+ const SCRIPTS: &str = "/home/adom/project/chip-fetcher/scripts"; /// Emboss the part number onto the chip's 3D STEP top face and render the
src/ui/index.htmladded+2222@@ -0,0 +1,2222 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>chip-fetcher viewer</title>+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2300b8b0'><rect x='6' y='6' width='12' height='12' rx='1.2'/><rect x='9' y='2' width='2' height='2'/><rect x='13' y='2' width='2' height='2'/><rect x='9' y='20' width='2' height='2'/><rect x='13' y='20' width='2' height='2'/><rect x='2' y='9' width='2' height='2'/><rect x='2' y='13' width='2' height='2'/><rect x='20' y='9' width='2' height='2'/><rect x='20' y='13' width='2' height='2'/></svg>">+<style>+ /* --- Adom canonical font loaders (per adom-app-header §Required @font-face loaders) --- */+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+ font-weight: 700; font-display: swap;+ }+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+ font-weight: 600; font-display: swap;+ }+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'Satoshi';+ src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+ font-weight: 400; font-display: swap;+ }+ @font-face {+ font-family: 'Satoshi';+ src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');+ font-weight: 400; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');+ font-weight: 600; font-display: swap;+ }++ :root {+ /* Canonical Adom tokens */+ --bg: #0d1117;+ --surface: #161b22;+ --elevated: #1c2128;+ --border: #30363d;+ --border-muted: #21262d;+ --text: #e6edf3;+ --text2: #7d8590;+ --text3: #484f58;+ --accent: #00b8b0; /* Adom teal — canonical brand primary accent */++ /* chip-fetcher semantic accents */+ --green: #3fb950;+ --amber: #f59e0b;+ --red: #f85149;+ --grey: #4b5563;++ /* legacy aliases (kept for existing rules below) */+ --panel: var(--surface);+ --muted: var(--text2);+ }+ * { box-sizing: border-box; }+ html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }+ body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; min-height: 100vh; }+ /* --- Canonical Adom app-header (single row, 44 px) --- */+ header {+ display: flex;+ align-items: center;+ gap: 12px;+ padding: 0 16px;+ height: 44px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--surface);+ position: relative;+ font-family: 'Familjen Grotesk', sans-serif;+ flex-shrink: 0;+ }+ header .brand {+ display: flex; align-items: center; gap: 8px;+ color: var(--accent);+ font-size: 13px; font-weight: 600;+ letter-spacing: 0.01em;+ cursor: default;+ flex-shrink: 0;+ outline: none;+ }+ header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+ header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+ header .brand .app-name { color: var(--accent); }+ header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+ header .brand .sep { color: var(--text3); font-weight: 400; }+ header .vsep {+ width: 1px; height: 18px;+ background: var(--border);+ flex-shrink: 0;+ }+ header .subject {+ display: flex; align-items: center; gap: 8px;+ min-width: 0;+ flex: 1;+ cursor: default;+ }+ header .subject-name {+ color: var(--text);+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 14px; font-weight: 600;+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;+ }+ header .subject-name.idle { color: var(--text3); font-weight: 500; }+ header .state-pill {+ display: none;+ font-size: 10px; font-weight: 700; letter-spacing: 0.5px;+ padding: 2px 8px;+ border-radius: 10px;+ font-family: 'Familjen Grotesk', sans-serif;+ flex-shrink: 0;+ }+ header .state-pill.show { display: inline-flex; }+ header .state-pill.full { background: rgba(63,185,80,0.18); color: var(--green); }+ header .state-pill.stub { background: rgba(245,158,11,0.20); color: var(--amber); }+ header .state-pill.fetching { background: rgba(0,184,176,0.20); color: var(--accent); animation: pillpulse 1.2s ease-in-out infinite; }+ @keyframes pillpulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }+ header .subject-meta {+ display: flex; align-items: center; gap: 6px;+ color: var(--text2);+ font-family: 'JetBrains Mono', monospace;+ font-size: 11px;+ white-space: nowrap;+ }+ header .subject-meta .sep { color: var(--text3); }+ @media (max-width: 720px) {+ header .subject-meta { display: none; }+ }+ header .actions {+ display: flex; align-items: center; gap: 6px;+ margin-left: auto; flex-shrink: 0;+ }++ /* --- Interactive popover (hover-tooltip you can move into) --- */+ header .hover-popover {+ position: absolute;+ top: calc(100% + 6px);+ left: 16px;+ background: var(--elevated);+ border: 1px solid var(--border);+ border-radius: 8px;+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);+ padding: 12px 14px;+ min-width: 300px;+ max-width: 420px;+ font-family: 'Satoshi', sans-serif;+ font-size: 12px;+ color: var(--text);+ z-index: 99999;+ opacity: 0; transform: translateY(-2px);+ pointer-events: none;+ transition: opacity 120ms ease, transform 120ms ease;+ }+ header .hover-popover.show {+ opacity: 1; transform: none;+ pointer-events: auto;+ }+ header .hover-popover.compact { min-width: 220px; }+ header .hover-popover .popover-title {+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 14px; font-weight: 700;+ color: var(--text);+ margin-bottom: 8px;+ }+ header .hover-popover .popover-row {+ display: flex; gap: 12px; padding: 3px 0;+ align-items: baseline;+ }+ header .hover-popover .popover-row .k {+ color: var(--text3);+ font-family: 'JetBrains Mono', monospace;+ font-size: 10px; text-transform: lowercase;+ width: 64px; flex-shrink: 0;+ }+ header .hover-popover .popover-row .v {+ color: var(--text);+ font-family: 'Satoshi', sans-serif;+ font-size: 12px;+ flex: 1;+ }+ header .hover-popover .popover-actions {+ margin-top: 10px; padding-top: 10px;+ border-top: 1px solid var(--border-muted);+ display: flex; gap: 12px;+ }+ header .hover-popover .popover-link {+ color: var(--accent);+ text-decoration: none;+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 12px; font-weight: 600;+ }+ header .hover-popover .popover-link:hover { text-decoration: underline; }++ /* --- Toolbar (filters / search / credentials) — separate row below header --- */+ .toolbar {+ display: flex; align-items: center; gap: 8px;+ padding: 8px 16px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--bg);+ flex-wrap: wrap;+ flex-shrink: 0;+ }+ .toolbar input[type=search],+ .toolbar select,+ .toolbar button {+ background: var(--surface);+ border: 1px solid var(--border);+ color: var(--text);+ padding: 6px 10px;+ border-radius: 6px;+ font: 12px/1.4 'Satoshi', sans-serif;+ }+ .toolbar input[type=search] { min-width: 220px; }+ .toolbar input[type=search]:focus,+ .toolbar select:focus,+ .toolbar button:hover { border-color: var(--accent); outline: none; }+ .toolbar button { cursor: pointer; }+ .toolbar .spacer { margin-left: auto; }+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }+ .modal-overlay.open { display: flex; }+ .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 24px; min-width: 480px; max-width: 600px; }+ .modal h3 { margin: 0 0 12px; font-size: 18px; }+ .modal .helper { color: var(--muted); font-size: 12px; margin-bottom: 16px; line-height: 1.5; }+ .modal label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }+ .modal input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 4px; font: inherit; }+ .modal .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }+ .modal .btn-primary { background: var(--accent); border: 1px solid var(--accent); color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }+ .modal .btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }+ .creds-list { margin-bottom: 16px; }+ .creds-list .row { display: flex; justify-content: space-between; align-items: center; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; font-size: 13px; }+ .creds-list .row .info { color: var(--muted); font-size: 11px; }+ .creds-list .row code { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text); }+ .creds-list .row button { background: transparent; border: 1px solid var(--border); color: var(--red); padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; }+ .msg { font-size: 13px; padding: 8px; border-radius: 4px; margin-top: 12px; }+ .msg.ok { background: rgba(34,197,94,.15); color: var(--green); }+ .msg.err { background: rgba(239,68,68,.15); color: var(--red); }+ main { display: grid; grid-template-columns: 1fr 380px; flex: 1; min-height: 0; }+ .grid { padding: 20px; overflow-y: auto; }+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; cursor: pointer; transition: border-color .15s; }+ .card:hover { border-color: var(--accent); }+ .card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.25); }+ .card .mpn-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; margin-bottom: 4px; }+ .card .mpn { font-weight: 600; font-size: 15px; word-break: break-all; flex: 1; min-width: 0; }+ .card .reveal-btn { flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 3px 5px; cursor: pointer; line-height: 0; display: inline-flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .12s, color .12s, border-color .12s; }+ .card:hover .reveal-btn, .card.selected .reveal-btn { opacity: 1; }+ .card .reveal-btn:hover { color: var(--accent); border-color: var(--accent); }+ .card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }+ /* Thumbnail strip — three conceptual slots: SYMBOL | FOOTPRINT | 3D GROUP.+ The 3D group bundles Z↑ + Y↑ + the ▾ orientation picker because they all represent+ "the STEP file shown from different angles" — distinct from SYM (solved symbol) and+ FP (solved footprint). */+ /* Two-tier thumbnail hierarchy: SYM + FP are the big primary artifacts you+ design with; the 3D up-axis QA + .lbr packaging action are a smaller+ secondary row beneath. */+ .card .thumbs { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }+ .card .thumb-row { display: grid; gap: 6px; }+ .card .thumb-row.primary { grid-template-columns: 1fr 1fr; }+ .card .thumb-row.secondary { grid-template-columns: 2fr 1fr; align-items: stretch; }+ .card .thumb-3d-group { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; position: relative; border: 1px solid var(--border-muted); border-radius: 6px; padding: 2px; background: rgba(64,75,85,.15); }+ .card .thumb-3d-group .orient-more-btn { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--panel); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 0 6px; font-size: 9px; line-height: 1.5; cursor: pointer; opacity: 0; transition: opacity .12s; }+ .card:hover .thumb-3d-group .orient-more-btn, .card.selected .thumb-3d-group .orient-more-btn { opacity: 1; }+ .card .thumb-3d-group .orient-more-btn:hover { color: var(--accent); border-color: var(--accent); }+ /* Card zoom — animate from the card's grid position to centered,+ so the user tracks which card is zooming. Uses CSS transition on+ top/left/transform with a short ease-out. */+ .card-zoom-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0); cursor: pointer; transition: background .25s ease-out; }+ .card-zoom-backdrop.visible { background: rgba(0,0,0,0.70); }+ .card-zoom-backdrop .card-zoomed-wrapper { position: fixed; z-index: 201; pointer-events: auto; cursor: default; transition: top .3s ease-out, left .3s ease-out, transform .3s ease-out; }+ .card-zoom-backdrop .card-zoomed-wrapper .card { transform-origin: top left; border: 2px solid var(--accent); box-shadow: 0 24px 80px rgba(0,0,0,0.7); }+ .card-zoom-backdrop .card .card-actions { opacity: 1 !important; }+ .card-zoom-backdrop .card .orient-more-btn { opacity: 1 !important; }+ .card-zoom-backdrop .card .reveal-btn { opacity: 1 !important; }+ /* Always-visible close button + full-screen scrollable mode (phones). */+ .card-zoom-x { position: fixed; top: 12px; right: 12px; z-index: 210; width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--border); background: rgba(13,17,23,.92); color: #e6edf3; font-size: 19px; line-height: 1; cursor: pointer; display: grid; place-items: center; box-shadow: 0 6px 22px rgba(0,0,0,.6); }+ .card-zoom-x:hover, .card-zoom-x:active { border-color: var(--accent); color: var(--accent); }+ .card-zoom-backdrop.fullscreen { overflow-y: auto; -webkit-overflow-scrolling: touch; display: flex; align-items: flex-start; justify-content: center; padding: 64px 0 36px; }+ .card-zoom-backdrop.fullscreen .card-zoomed-wrapper { position: relative !important; left: auto !important; top: auto !important; }+ /* Fluid 2-column zoom (desktop): media (thumbnails) on the LEFT, title + meta ++ badges + Open-in/Send-to on the RIGHT — so a zoomed card fills the horizontal+ room and stays short enough to scale up large, instead of a tall narrow strip. */+ .card-zoom-backdrop .card.zoom-fluid { display: grid; grid-template-columns: minmax(320px, 1.25fr) minmax(300px, 1fr); column-gap: 26px; align-items: start; padding: 22px; }+ .card-zoom-backdrop .card.zoom-fluid .zoom-col-media,+ .card-zoom-backdrop .card.zoom-fluid .zoom-col-info { min-width: 0; display: flex; flex-direction: column; gap: 10px; }+ .card-zoom-backdrop .card.zoom-fluid .thumbs { margin: 0; }+ .card-zoom-backdrop .card.zoom-fluid .zoom-actions { margin-top: 2px; }+ /* In the ZOOM, show the FULL chip description (what the part actually is) —+ not the 2-line clamp the grid card uses. John's repeated ask: the zoom must+ explain the chip. Make it prominent and fully readable. */+ .card-zoom-backdrop .card .card-desc {+ -webkit-line-clamp: unset; display: block; overflow: visible;+ font-size: 14px; line-height: 1.5; color: var(--text);+ margin: 6px 0 12px; max-width: 60ch;+ }+ /* Tile background MATCHES the image's own baked-in canvas color, so any+ whitespace introduced by aspect-ratio mismatch (KiCad SVG vs square+ tile) blends seamlessly. Per-kind backgrounds via data-thumb-kind:+ sym/fp → Adom dark canvas #0d1117 (chip-thumbnailer's SVG bg)+ 3d → slate #404B55 (pyrender's iso-pose backdrop) */+ .card .thumb { background: #0d1117; border: 1px solid var(--border-muted); border-radius: 6px; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; padding: 8%; overflow: hidden; position: relative; transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }+ .card .thumb[data-thumb-kind="chip3d"], .card .thumb[data-thumb-kind="chip3d-yup"] { background: #404B55; padding: 4%; }+ .card .thumb.thumb-lbr { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; color:#8aa0b8; background:linear-gradient(150deg,#1b2330,#222c3d); }+ .card .thumb.thumb-lbr svg { width:34%; height:auto; }+ .card .thumb.thumb-lbr:hover { color:#37c8a6; }+ /* Active orientation gets an Adom-teal highlight ring + corner check.+ The OTHER 3D thumb sits dimmer until hover, signalling "click to pick+ this one if the active is wrong". */+ .card .thumb[data-orient-active="true"] { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .thumb[data-orient-inactive="true"] { opacity: 0.55; }+ .card .thumb[data-orient-inactive="true"]:hover { opacity: 1; }+ .card .thumb .orient-check { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #0d1117; pointer-events: none; }+ .card .thumb .orient-check svg { width: 11px; height: 11px; }+ .card .thumb:hover { border-color: var(--accent); transform: scale(1.06); z-index: 2; box-shadow: 0 6px 18px rgba(0,0,0,0.4), 0 0 0 1px var(--accent); }+ /* Symbol/footprint tiles are non-interactive — no hover lift, no pointer. */+ .card .thumb.thumb-static { cursor: default; }+ .card .thumb.thumb-static:hover { transform: none; box-shadow: none; border-color: var(--border-muted); }+ .card .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }+ .card .thumb .thumb-label { position: absolute; bottom: 4px; right: 6px; font-size: 9px; color: rgba(230,237,243,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-shadow: 0 1px 2px rgba(0,0,0,0.6); pointer-events: none; }+ .card .thumb.empty { background: var(--bg); border-style: dashed; padding: 8%; }+ .card .thumb.empty .thumb-placeholder { color: var(--text3); font-size: 10px; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; }+ /* Render FAILED (chip-thumbnailer errored) — distinct from "not generated"+ so a failure isn't indistinguishable from an absent source file. */+ .card .thumb.failed { background: rgba(220,80,60,.06); border-style: dashed; border-color: rgba(220,80,60,.45); padding: 6%; flex-direction: column; gap: 2px; }+ .card .thumb.failed .thumb-failed { color: #ef7a6a; font-size: 10px; font-family: 'JetBrains Mono', monospace; font-weight: 600; letter-spacing: .05em; }+ .card .thumb.failed .thumb-failed-sub { color: var(--text3); font-size: 8px; font-family: 'JetBrains Mono', monospace; }+ /* Active-card emphasis — when this card is the one being heartbeated. */+ .card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); animation: cardpulse 1.4s ease-in-out infinite; }+ @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); } 50% { box-shadow: 0 0 0 2px rgba(0,184,176,.55), 0 0 32px rgba(0,184,176,.30); } }+ .badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }+ .b { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600; letter-spacing: .3px; }+ .b.step { background: rgba(34,197,94,.15); color: var(--green); }+ .b.kicad { background: rgba(34,197,94,.15); color: var(--green); }+ .b.altium { background: rgba(34,197,94,.15); color: var(--green); }+ .b.fusion { background: rgba(34,197,94,.15); color: var(--green); }+ .b.partial-eda { background: rgba(245,158,11,.18); color: var(--amber); }+ .b.pdf { background: rgba(0,184,176,.15); color: var(--accent); }+ .b.clickable { cursor: pointer; }+ .b.clickable:hover { filter: brightness(1.25); }+ .b.miss { background: rgba(75,85,99,.25); color: var(--grey); }+ .b.full { background: rgba(34,197,94,.2); color: var(--green); }+ .b.stub { background: rgba(245,158,11,.2); color: var(--amber); }+ .b.src.mfr { background: rgba(34,197,94,.2); color: var(--green); }+ .b.src.fb { background: rgba(245,158,11,.2); color: var(--amber); }+ .b.src.unk { background: rgba(75,85,99,.25); color: var(--grey); }+ .b.lifecycle-unk { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }+ .b.stock-yes { background: rgba(34,197,94,.18); color: var(--green); }+ .b.stock-low { background: rgba(245,158,11,.20); color: var(--amber); }+ .b.stock-no { background: rgba(239,68,68,.18); color: var(--red); }+ .b.stock-unk { background: rgba(75,85,99,.20); color: var(--grey); }+ .b.price { background: rgba(0,184,176,.10); color: var(--accent); font-weight: 500; }+ .b.nrnd { background: rgba(239,68,68,.20); color: var(--red); font-weight: 700; letter-spacing: .5px; }+ .b.eol { background: rgba(220,38,38,.30); color: #fca5a5; font-weight: 700; letter-spacing: .5px; }+ .b.new { background: rgba(34,197,94,.18); color: var(--green); font-weight: 600; letter-spacing: .3px; }+ /* concur validation badges */+ .b.val-golden { background: rgba(34,197,94,.25); color: var(--green); font-weight: 700; }+ .b.val-golden-v { background: rgba(34,197,94,.15); color: var(--green); font-weight: 600; }+ .b.val-divergent { background: rgba(245,158,11,.30); color: var(--amber); font-weight: 700; letter-spacing: .3px; }+ .b.val-bad { background: rgba(239,68,68,.25); color: var(--red); font-weight: 700; }+ .b.val-none { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }+ /* per-card action buttons (re-validate, re-thumbnails, refetch) */+ .card .card-actions { display: flex; gap: 4px; align-items: center; flex: 0 0 auto; opacity: 0; transition: opacity .12s; }+ .card:hover .card-actions, .card.selected .card-actions { opacity: 1; }+ /* Touch devices have no hover — keep the card actions (incl. the zoom button)+ visible so a tap can reach them. */+ @media (hover: none) { .card .card-actions { opacity: 1; } }+ .card .card-btn { background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; line-height: 1.4; transition: color .12s, border-color .12s; }+ .card .card-btn:hover { color: var(--accent); border-color: var(--accent); }+ .card .card-btn:disabled { cursor: wait; }+ #cf-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(16px); z-index: 100050; max-width: 90vw; background: rgba(17,22,31,.98); border: 1px solid var(--border2, #2b3645); border-radius: 11px; padding: 11px 16px; font-size: 13px; color: var(--text, #e6edf3); box-shadow: 0 14px 44px rgba(0,0,0,.6); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }+ #cf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }+ #cf-toast a { color: var(--accent, #00b8b1); font-weight: 700; margin-left: 6px; }+ .openin-row { display: flex; gap: 6px; flex-wrap: wrap; }+ /* Actions block injected into the zoom focus view. */+ .zoom-actions { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }+ .zoom-actions .zoom-sec { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin: 0 0 6px; }+ .zoom-actions .openin-row, .zoom-actions .sendto-row { margin-bottom: 12px; }+ .openin-row .openbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }+ .openin-row .openbtn:hover { background: rgba(0,184,177,.16); border-color: var(--accent, #00b8b1); color: var(--accent, #00b8b1); }+ .openin-row .openbtn:disabled { opacity: .55; cursor: wait; }+ .sendto-row { display: flex; gap: 6px; flex-wrap: wrap; }+ .sendto-row .sendbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }+ .sendto-row .sendbtn:hover { background: var(--accent, #00b8b1); border-color: var(--accent, #00b8b1); color: #04201f; }+ .sendto-row .sendbtn:disabled { opacity: .55; cursor: progress; }+ #bridgeModal { position: fixed; inset: 0; z-index: 100001; display: none; align-items: center; justify-content: center; background: rgba(2,5,10,.62); backdrop-filter: blur(4px); padding: 20px; }+ #bridgeModal.show { display: flex; }+ #bridgeModal .bm-card { max-width: min(94vw,440px); background: rgba(17,22,31,.99); border: 1px solid var(--border); border-radius: 16px; padding: 22px 22px 18px; box-shadow: 0 30px 90px rgba(0,0,0,.7); text-align: center; }+ #bridgeModal .bm-icon { font-size: 34px; margin-bottom: 6px; }+ #bridgeModal .bm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }+ #bridgeModal .bm-body { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px; }+ #bridgeModal .bm-actions { display: flex; flex-direction: column; gap: 9px; }+ #bridgeModal .bm-go { display: block; text-decoration: none; background: linear-gradient(180deg,#00b8b1,#009a94); color: #04201f; font-weight: 700; font-size: 13px; padding: 10px; border-radius: 10px; }+ #bridgeModal .bm-hint { font-size: 11.5px; line-height: 1.5; color: var(--text, #cfd6e4); background: rgba(0,184,177,.08); border: 1px solid rgba(0,184,177,.22); border-radius: 9px; padding: 8px 10px; margin-bottom: 14px; text-align: left; }+ #bridgeModal .bm-contribute { display: block; text-decoration: none; color: var(--muted); font-size: 12px; padding: 7px; border: 1px solid var(--border); border-radius: 10px; }+ #bridgeModal .bm-contribute:hover { color: var(--accent); border-color: var(--accent); }+ #bridgeModal .bm-dismiss { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: 12.5px; padding: 8px; border-radius: 10px; cursor: pointer; }+ .card .refetch-select { font-size: 11px; padding: 1px 4px; width: 28px; background: var(--panel); color: var(--text); border: 1px solid var(--border); color-scheme: dark; overflow: hidden; text-overflow: ellipsis; }+ .card .refetch-select option { background: var(--bg); color: var(--text); padding: 4px 8px; font-family: Satoshi, -apple-system, sans-serif; font-size: 12px; }+ /* authoritative footprint SVG (ds2sf v0.5.1+) */+ .card .auth-svg-thumb { position: relative; margin: 8px 0 4px; padding: 6px; background: #fff; border: 1px solid var(--border); border-radius: 6px; aspect-ratio: 2.4 / 1; display: flex; align-items: center; justify-content: center; overflow: hidden; }+ .card .auth-svg-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }+ .card .auth-svg-thumb.auth-canonical { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.3) inset; }+ .card .auth-svg-thumb.auth-synth { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(245,158,11,.3) inset; }+ .card .auth-svg-thumb .auth-svg-label { position: absolute; bottom: 2px; right: 4px; font-size: 9px; color: rgba(0,0,0,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; pointer-events: none; }+ /* Auto-zoom toggle in toolbar */+ .auto-zoom-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; margin-left: 8px; user-select: none; }+ .auto-zoom-toggle input { accent-color: var(--accent); }+ /* 6-orientation popover (button lives inside .thumb-3d-group) */+ .card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }+ /* symbol/footprint provenance carat — always-visible tappable source chip */+ .card .thumb-src-group { position: relative; display: flex; }+ .card .thumb-src-group .thumb { flex: 1; }+ .card .src-chip { position: absolute; top: 4px; left: 4px; z-index: 4; display: inline-flex; align-items: center; gap: 4px;+ font-size: 8.5px; font-weight: 700; letter-spacing: .02em; color: #d7efed; background: rgba(13,17,23,.82); backdrop-filter: blur(4px);+ border: 1px solid rgba(0,184,177,.45); border-radius: 5px; padding: 2px 5px; cursor: pointer; max-width: 88%;+ transition: border-color .12s, background .12s; -webkit-tap-highlight-color: transparent; }+ .card .src-chip:hover, .card .src-chip:active { border-color: var(--accent); background: rgba(0,184,177,.16); }+ .card .src-chip-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+ .card .src-chip-car { color: var(--muted); font-size: 8px; }+ .card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px; min-width: 168px;+ display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 28px rgba(0,0,0,.55); }+ .card .src-opt { cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-muted); border-radius: 6px;+ padding: 5px 7px; font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; white-space: nowrap; }+ .card .src-opt:hover, .card .src-opt:active { border-color: var(--accent); background: rgba(0,184,177,.08); }+ .card .src-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .src-opt-thumb { width: 30px; height: 30px; object-fit: contain; background: #0d1117; border-radius: 4px; flex: 0 0 30px; }+ .card .src-opt-lab { flex: 1; }+ .card .src-cur { color: var(--green); font-size: 9px; }+ .card .src-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }+ .card .src-dot.vendor { background: #00b8b1; }+ .card .src-dot.ds { background: #d29922; }+ .card .orient-opt { cursor: pointer; border: 1px solid var(--border-muted); border-radius: 6px; padding: 4px; background: #404B55; text-align: center; transition: border-color .12s; }+ .card .orient-opt:hover { border-color: var(--accent); }+ .card .orient-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .orient-opt img { width: 48px; height: 48px; object-fit: contain; display: block; margin: 0 auto 2px; }+ .card .orient-opt span { font-size: 9px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }+ aside { background: var(--panel); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; }+ aside.empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }+ /* Tabbed detail panel — keeps the 66-file list from dominating. */+ .dtabs { display: flex; gap: 4px; margin: 14px 0 12px; border-bottom: 1px solid var(--border); }+ .dtab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-size: 13px; font-weight: 600; padding: 7px 10px; cursor: pointer; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 5px; }+ .dtab:hover { color: var(--text, #e6edf3); }+ .dtab.on { color: var(--accent, #00b8b1); border-bottom-color: var(--accent, #00b8b1); }+ .dtab .dtab-n { font-size: 10px; background: var(--panel2, #0f141c); border: 1px solid var(--border); border-radius: 8px; padding: 0 5px; color: var(--muted); }+ .dpanel { display: none; }+ .dpanel.on { display: block; }+ .filelist { margin: 0; padding: 0; list-style: none; max-height: calc(100vh - 220px); overflow-y: auto; }+ .filelist li { padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04); }+ .filelist a { color: var(--accent); text-decoration: none; font-size: 11.5px; word-break: break-all; }+ aside h2 { margin: 0 0 8px; font-size: 18px; }+ aside .vendor { color: var(--muted); margin-bottom: 16px; }+ aside .section { margin-bottom: 18px; }+ aside .section-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }+ aside ul { margin: 0; padding-left: 0; list-style: none; }+ aside ul li { padding: 4px 0; border-bottom: 1px solid var(--border); }+ aside ul li:last-child { border-bottom: none; }+ aside a { color: var(--accent); text-decoration: none; }+ aside a:hover { text-decoration: underline; }+ aside pre { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; font-size: 11px; color: var(--muted); overflow-x: auto; max-height: 300px; }+ aside .fields { display: flex; flex-direction: column; gap: 0; }+ aside .field { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border-muted); font-size: 12px; align-items: baseline; }+ aside .field:last-child { border-bottom: none; }+ aside .field-key { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; letter-spacing: 0.02em; }+ aside .field-val { color: var(--text); font-family: 'Satoshi', sans-serif; word-break: break-word; }+ aside .field-val a { color: var(--accent); }+ aside .field.unknown .field-val { color: var(--text3); font-style: italic; font-family: 'JetBrains Mono', monospace; font-size: 11px; }+ .empty-state { color: var(--muted); padding: 40px; text-align: center; }+ .card .card-desc { color: var(--text2); font-size: 11px; line-height: 1.35; margin: 4px 0 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }+ .out-of-filter-note { grid-column: 1 / -1; background: rgba(230,180,80,.10); border: 1px solid rgba(230,180,80,.35); color: var(--text2); border-radius: 6px; padding: 8px 12px; font-size: 12px; display: flex; align-items: center; gap: 10px; }+ .out-of-filter-note b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }+ .out-of-filter-note .ofn-clear { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-left: auto; }+ .out-of-filter-note .ofn-clear:hover { filter: brightness(1.1); }+ .activity { display: none; padding: 10px 24px; background: rgba(0,184,176,.10); border-bottom: 1px solid var(--accent); font-size: 13px; color: var(--text); align-items: center; gap: 12px; }+ .activity.live { display: flex; }+ /* Stale: the last heartbeat is old enough that no work is actually+ happening. Show it muted with the age, instead of a permanent WORKING. */+ .activity.stale { display: flex; background: rgba(120,130,140,.07); border-bottom-color: var(--border); }+ .activity.stale .dot { background: var(--text3); animation: none; }+ .activity.stale .mpn-tag { color: var(--text3); }+ .activity .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }+ .activity .label { color: var(--muted); }+ .activity .mpn-tag { font-family: "JetBrains Mono", monospace; font-weight: 600; color: var(--accent); }+ .activity .stage { color: var(--muted); }+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }+ .card.active { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,.45); animation: cardpulse 1.2s ease-in-out infinite; position: relative; }+ .card.active::after { content: "WORKING"; position: absolute; top: -8px; right: 8px; background: var(--accent); color: white; font-size: 9px; padding: 2px 6px; border-radius: 3px; letter-spacing: .5px; font-weight: 700; }+ @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 3px rgba(0,184,176,.45); } 50% { box-shadow: 0 0 0 6px rgba(0,184,176,.20); } }+ /* --- Project tabs --- */+ .project-tabs { display: flex; flex-wrap: wrap; }+ .project-tab {+ font: 500 11px/1 'JetBrains Mono', monospace;+ padding: 4px 10px; border-radius: 4px; cursor: pointer;+ background: var(--surface); border: 1px solid var(--border-muted);+ color: var(--text2); transition: all 0.15s;+ }+ .project-tab:hover { background: var(--elevated); color: var(--text); }+ .project-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }+ .project-tab .count { opacity: 0.7; margin-left: 4px; }+</style>+</head>+<body>+<header>+ <div class="brand" data-tooltip-target="app-info" tabindex="0">+ <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+ <rect x="6" y="6" width="12" height="12" rx="1.2"/>+ <rect x="9" y="2" width="2" height="2"/><rect x="13" y="2" width="2" height="2"/>+ <rect x="9" y="20" width="2" height="2"/><rect x="13" y="20" width="2" height="2"/>+ <rect x="2" y="9" width="2" height="2"/><rect x="2" y="13" width="2" height="2"/>+ <rect x="20" y="9" width="2" height="2"/><rect x="20" y="13" width="2" height="2"/>+ </svg>+ <span class="app-name">adom-chip-fetcher</span>+ <span class="sep">·</span>+ <span class="app-subtitle">manufacturer CAD bundles</span>+ </div>++ <div class="vsep" aria-hidden="true"></div>++ <div class="subject">+ <span class="subject-name idle" id="subjectName">—</span>+ <span class="state-pill" id="subjectPill"></span>+ <span class="subject-meta" id="subjectMeta">+ <span class="sep">·</span><span id="metaFull">0 full</span>+ <span class="sep">·</span><span id="metaStubs">0 stubs</span>+ <span class="sep">·</span><span id="metaSize">0 MB</span>+ </span>+ </div>++ <div class="actions"></div>++ <!-- App-info popover (interactive tooltip — stays open on hover) -->+ <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+ <div class="popover-title">adom-chip-fetcher</div>+ <div class="popover-row"><span class="k">about</span>+ <span class="v">Download manufacturer-grade chip CAD bundles — STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet — into a structured library by driving Chrome via pup. Manufacturer-first sourcing ladder, then SnapEDA / Mouser / DigiKey / Ultra Librarian / Component Search Engine.</span></div>+ <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.2</span></div>+ <div class="popover-row"><span class="k">built</span><span class="v" id="popBuilt">2026-05-03</span></div>+ <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+ <div class="popover-actions">+ <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher" target="_blank" rel="noopener">Open wiki ↗</a>+ <a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub ↗</a>+ </div>+ </div>+</header>++<div class="toolbar" role="toolbar" aria-label="Library filters">+ <input type="search" id="q" placeholder="search MPN, vendor, package…">+ <select id="vendor"><option value="">all vendors</option></select>+ <select id="status">+ <option value="">all status</option>+ <option value="full">fully-bundled</option>+ <option value="stub">stubs only</option>+ <option value="no-info">missing info.json</option>+ <option value="no-pdf">missing datasheet</option>+ </select>+ <button id="btn-validate" class="spacer" data-tooltip="Full self-audit: a ✓/✗ matrix of EVERY required file for EVERY chip (datasheet, STEP, symbol, footprint, and the 3 card thumbnails). Goes green ONLY when truly complete — so 'done' can't be faked.">✓ Validation</button>+ <button id="btn-sources" data-tooltip="The exact priority order chip-fetcher uses to search the entire internet for electronics data: manufacturer site first, then the Adom data tools (parts-search / Mouser / DigiKey / JLCPCB), then the manual CAD-crawl skills (SnapMagic / CSE / UltraLibrarian), with LCSC dead last.">⛓ Sourcing order</button>+ <button id="btn-creds" data-tooltip="Manage saved credentials for vendor portals (Component Search Engine, etc.).\nUsed to authenticate downloads from SnapEDA / Ultra Librarian / DigiKey when a chip needs a login.">Credentials</button>+ <button id="btn-reveal-lib" data-tooltip="Reveal the chip-fetcher library folder in the VS Code Explorer sidebar.\nOpens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg>Library folder</button>+ <label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb" checked> auto-zoom</label>+</div>++<div class="project-tabs" id="project-tabs" style="display:none;padding:0 16px 6px;gap:6px;align-items:center">+ <span style="font-size:11px;color:var(--text2);margin-right:4px">project:</span>+</div>++<div class="activity" id="activity-bar">+ <span class="dot"></span>+ <span class="label">working on</span>+ <span class="mpn-tag" id="activity-mpn"></span>+ <span class="stage" id="activity-stage"></span>+</div>++<div class="modal-overlay" id="creds-modal">+ <div class="modal">+ <h3>Saved credentials</h3>+ <div class="helper">+ Stored at <code>~/.config/chip-fetcher/credentials.json</code> (chmod 600 — only readable by your container user). chip-fetcher rewrites outbound URLs to <code>https://user:pass@host/…</code> so vendor portals like Component Search Engine never show a basic-auth popup.+ <br><br>+ Passwords are masked by default — click the dots to reveal or hide. The vault is plaintext on disk (vendor-portal creds aren't bank logins) but the UI keeps them off-screen so a casual screenshot doesn't leak them.+ </div>+ <div class="creds-list" id="creds-list"><div class="info">loading…</div></div>++ <h3 style="margin-top:24px">Add / update</h3>+ <label>Host pattern (glob)</label>+ <input id="cred-host" placeholder="*.componentsearchengine.com" value="*.componentsearchengine.com">+ <label>Username</label>+ <input id="cred-user" placeholder="[email protected]">+ <label>Password</label>+ <input id="cred-pass" type="password" placeholder="password">++ <div class="buttons">+ <button class="btn-secondary" id="cred-close">Close</button>+ <button class="btn-primary" id="cred-save">Save credentials</button>+ </div>+ <div id="cred-msg"></div>+ </div>+</div>+<main>+ <div class="grid" id="grid-view">+ <div class="cards" id="cards"><div class="empty-state">loading library…</div></div>+ </div>+ <div id="validation-view" style="display:none;grid-column:1/-1;padding:18px 20px;overflow:auto"></div>+ <div id="sources-view" style="display:none;grid-column:1/-1;padding:18px 24px;overflow:auto"></div>+ <aside class="empty" id="detail">select a chip to see details</aside>+</main>+<script>+let LIB = null;+let SELECTED = null;+let DETAIL_TAB = 'actions'; // which detail-panel tab is open (Actions | Details | Files)+// Per-chip+kind source the user picked in the provenance carat (e.g. "ultralibrarian").+// Keyed "mpn|kind". Persisted so the choice sticks across reloads.+let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch { return {}; } })();+function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch {} }+// The source method to treat as active for a tile: the user's pick if it's still+// a valid source, else the backend canonical ("current").+function activeMethod(mpn, kind, sources) {+ const key = mpn + '|' + kind;+ const pick = SRC_SEL[key];+ if (pick && sources.some(s => s.method === pick)) return pick;+ const cur = sources.find(s => s.current);+ return cur ? cur.method : (sources[0] && sources[0].method);+}+let ACTIVE_MPN = null;+let ACTIVE_STAGE = '';+let LAST_LIB_HASH = '';+let PROJECTS = null;+let ACTIVE_PROJECT_FILTER = null; // null = show all, string = filter to project++// --- Interactive hover-popover (600 ms show-delay, 220 ms hide-grace) ---+// per adom-app-header §Reference JS + human-ui-patterns §1b (600 ms).+// Hover-intent prevents fly-by triggers; the hide-grace lets the user+// move from the brand block into the popover to click the wiki link.+(function(){+ const SHOW_DELAY = 600; // human-ui-patterns §1b+ const HIDE_GRACE = 220;+ document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+ const target = document.getElementById(trigger.dataset.tooltipTarget);+ if (!target) return;+ let showTimer = null, hideTimer = null;+ const show = () => {+ clearTimeout(hideTimer);+ if (target.classList.contains('show')) return;+ clearTimeout(showTimer);+ showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+ };+ const queueHide = () => {+ clearTimeout(showTimer);+ clearTimeout(hideTimer);+ hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+ };+ trigger.addEventListener('mouseenter', show);+ trigger.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('focus', show);+ trigger.addEventListener('blur', queueHide);+ target.addEventListener('mouseenter', show);+ target.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('click', e => {+ clearTimeout(showTimer);+ target.classList.toggle('show');+ e.stopPropagation();+ });+ document.addEventListener('click', e => {+ if (!target.contains(e.target) && !trigger.contains(e.target)) target.classList.remove('show');+ });+ });+})();++// Update the header subject zone — currently-fetched/selected MPN + state-pill+// + library-level meta counts. Called from pollActivity, load, and select so+// the header reflects "what we're doing right now" continuously.+function updateSubject() {+ const nameEl = document.getElementById('subjectName');+ const pillEl = document.getElementById('subjectPill');+ const focusMpn = ACTIVE_MPN || SELECTED || null;+ if (focusMpn) {+ nameEl.textContent = focusMpn;+ nameEl.classList.remove('idle');+ pillEl.classList.add('show');+ if (ACTIVE_MPN === focusMpn) {+ pillEl.textContent = 'FETCHING';+ pillEl.className = 'state-pill show fetching';+ pillEl.setAttribute('data-tooltip', ACTIVE_STAGE || 'fetching…');+ } else {+ const chip = LIB && LIB.chips.find(c => c.mpn === focusMpn);+ if (chip && chip.fully_bundled) {+ pillEl.textContent = 'FULL';+ pillEl.className = 'state-pill show full';+ pillEl.setAttribute('data-tooltip', 'Fully bundled — STEP 3D + schematic symbol + PCB footprint + datasheet are all present.');+ } else {+ pillEl.textContent = 'STUB';+ pillEl.className = 'state-pill show stub';+ pillEl.setAttribute('data-tooltip', chip ? 'Missing one or more files (STEP / symbol / footprint / datasheet). Use the ⤓ menu to fetch the rest.' : 'unknown');+ }+ }+ } else {+ nameEl.textContent = '—';+ nameEl.classList.add('idle');+ pillEl.classList.remove('show');+ pillEl.removeAttribute('title');+ }+ if (LIB) {+ document.getElementById('metaFull').textContent = `${LIB.fully_count} full`;+ document.getElementById('metaStubs').textContent = `${LIB.stub_count} stubs`;+ document.getElementById('metaSize').textContent = `${(LIB.total_size/1024/1024).toFixed(0)} MB`;+ }+}++// Use relative paths so this works behind /proxy/<port>/ tunnels too.+const API_BASE = (() => {+ const p = location.pathname;+ // strip trailing index.html and trailing slash+ return p.replace(/index\.html$/, '').replace(/\/$/, '');+})();++// A heartbeat older than this many seconds is treated as "not actively+// working" — heartbeats are write-only stage labels with no terminal state,+// so without an age check the last one ("fetching X") sticks forever.+const ACTIVITY_STALE_SECS = 25;++function agoLabel(s) {+ if (s == null) return '';+ if (s < 60) return s + 's ago';+ if (s < 3600) return Math.floor(s / 60) + 'm ago';+ return Math.floor(s / 3600) + 'h ago';+}++// Re-fetch the library and re-render iff the meaningful per-chip state+// changed. Returns true if it re-rendered. Called from pollActivity AND a+// steady timer, so card bodies (files, thumbnails, validation) stay fresh+// even when nothing is actively fetching — previously the library was only+// re-polled while a heartbeat was live, so idle cards went stale until reload.+async function refreshLibraryIfChanged() {+ try {+ const libR = await fetch(API_BASE + '/api/library');+ const newLib = await libR.json();+ const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status]));+ if (hash !== LAST_LIB_HASH) {+ LIB = newLib;+ LAST_LIB_HASH = hash;+ render();+ updateSubject();+ renderProjectTabs();+ if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }+ return true;+ }+ } catch (e) { /* transient */ }+ return false;+}++// Poll /api/activity every 1s. Drives the banner + the WORKING card highlight.+async function pollActivity() {+ try {+ const r = await fetch(API_BASE + '/api/activity');+ const a = await r.json();+ const mpn = a && a.mpn ? a.mpn : null;+ const stage = a && a.stage ? a.stage : '';+ const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;+ const bar = document.getElementById('activity-bar');+ const mpnEl = document.getElementById('activity-mpn');+ const stEl = document.getElementById('activity-stage');+ const terminalStage = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';+ const stale = age != null && age > ACTIVITY_STALE_SECS;+ const working = mpn && !terminalStage && !stale;++ if (working) {+ bar.classList.add('live');+ bar.classList.remove('stale');+ mpnEl.textContent = mpn;+ stEl.textContent = stage ? '— ' + stage : '';+ const stageChanged = stage !== ACTIVE_STAGE;+ ACTIVE_STAGE = stage;+ if (mpn !== ACTIVE_MPN) {+ ACTIVE_MPN = mpn;+ render();+ updateSubject();+ scrollActiveCardIntoView();+ autoZoomIfEnabled(mpn);+ } else if (stageChanged) {+ updateSubject();+ }+ await refreshLibraryIfChanged();+ } else {+ // Clear the WORKING highlight the moment work stops/goes stale.+ if (ACTIVE_MPN !== null) {+ ACTIVE_MPN = null;+ ACTIVE_STAGE = '';+ render();+ updateSubject();+ }+ // Don't lie with a permanent green WORKING bar. If there's a stale+ // last-heartbeat, show it muted with its age; otherwise hide the bar.+ if (mpn && stale && stage) {+ bar.classList.remove('live');+ bar.classList.add('stale');+ mpnEl.textContent = mpn;+ stEl.textContent = '— idle · last: ' + stage + (age != null ? ' · ' + agoLabel(age) : '');+ } else {+ bar.classList.remove('live', 'stale');+ }+ }+ } catch (e) { /* ignore transient failures */ }+}+setInterval(pollActivity, 1000);+// Keep card bodies live even when idle (no active heartbeat).+setInterval(refreshLibraryIfChanged, 4000);++async function load() {+ const r = await fetch(API_BASE + '/api/library');+ LIB = await r.json();+ updateSubject();+ const vendors = [...new Set(LIB.chips.map(c => c.vendor))].sort();+ const vsel = document.getElementById('vendor');+ for (const v of vendors) {+ const o = document.createElement('option'); o.value = v; o.textContent = v;+ vsel.appendChild(o);+ }+ render();+ loadProjects();+}++async function loadProjects() {+ try {+ const r = await fetch(API_BASE + '/api/projects');+ if (!r.ok) return;+ PROJECTS = await r.json();+ // On first load, honor the server's active project so the dashboard opens+ // already filtered (set via `chip-fetcher project activate <name>`), instead+ // of dumping the full unfiltered library. The user can still tap "All".+ if (!window._projInit) {+ window._projInit = true;+ if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) {+ ACTIVE_PROJECT_FILTER = PROJECTS.active;+ }+ }+ renderProjectTabs();+ applyDeepLink();+ } catch {}+}++// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1 open the+// dashboard already filtered to the project, the chip selected + scrolled into+// view (with an optional source preselected), and optionally zoomed — so a+// shared link lands exactly on what to look at.+function applyDeepLink() {+ if (window._deepLinkDone || !LIB) return;+ window._deepLinkDone = true;+ const p = new URLSearchParams(location.search);+ const proj = p.get('project'), chip = p.get('chip'), source = p.get('source');+ const zoom = p.get('zoom'); const wantZoom = zoom && zoom !== '0' && zoom !== 'false';+ if (!proj && !chip) return;+ if (proj && PROJECTS && PROJECTS.projects && PROJECTS.projects[proj]) {+ ACTIVE_PROJECT_FILTER = proj; renderProjectTabs();+ }+ if (source && chip) {+ SRC_SEL[chip + '|symbol'] = source; SRC_SEL[chip + '|footprint'] = source; saveSrcSel();+ }+ render();+ if (chip) {+ setTimeout(() => {+ select(chip);+ const card = document.querySelector(`.card[data-mpn="${CSS.escape(chip)}"]`);+ if (card) {+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });+ if (wantZoom) setTimeout(() => zoomCard(card), 380);+ }+ }, 160);+ }+}++function renderProjectTabs() {+ const bar = document.getElementById('project-tabs');+ if (!PROJECTS || !PROJECTS.projects || Object.keys(PROJECTS.projects).length === 0) {+ bar.style.display = 'none';+ return;+ }+ bar.style.display = 'flex';+ const label = bar.querySelector('span');+ bar.innerHTML = '';+ bar.appendChild(label);++ // "All" tab+ const allTab = document.createElement('div');+ allTab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === null ? ' active' : '');+ allTab.innerHTML = `Library<span class="count">${LIB ? LIB.chips.length : 0}</span>`;+ allTab.onclick = () => { ACTIVE_PROJECT_FILTER = null; renderProjectTabs(); render(); };+ bar.appendChild(allTab);++ // Per-project tabs+ for (const [name, proj] of Object.entries(PROJECTS.projects)) {+ const tab = document.createElement('div');+ tab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === name ? ' active' : '');+ tab.innerHTML = `${name}<span class="count">${proj.mpns.length}</span>`;+ tab.onclick = () => { ACTIVE_PROJECT_FILTER = name; renderProjectTabs(); render(); };+ bar.appendChild(tab);+ }++ // NOTE: We deliberately do NOT auto-apply the server's persisted "active"+ // project here. That field survives across sessions, so auto-narrowing the+ // grid to it silently hid freshly-fetched chips behind a stale filter from+ // weeks-old, unrelated work. The dashboard always opens on the full Library;+ // the user clicks a project tab to filter. (PROJECTS.active is still shown+ // highlighted if you want to know what the CLI considers active.)+}++function srcTier(src) {+ if (src.startsWith('mfr-direct')) return 'mfr';+ if (src.startsWith('mfr-')) return 'mfr';+ if (src.startsWith('mouser') || src.startsWith('digikey') || src.startsWith('ul-')) return 'fb';+ return 'unk';+}++function srcShort(src) {+ if (src === '—' || !src) return '?';+ return src.replace('-fallback','').replace('mouser-','mouser/').replace('mfr-','');+}++function render() {+ const q = document.getElementById('q').value.toLowerCase();+ const v = document.getElementById('vendor').value;+ const s = document.getElementById('status').value;+ const cards = document.getElementById('cards');+ cards.innerHTML = '';+ const filtered = LIB.chips.filter(c => {+ if (ACTIVE_PROJECT_FILTER && PROJECTS) {+ const proj = PROJECTS.projects[ACTIVE_PROJECT_FILTER];+ if (proj && !proj.mpns.includes(c.mpn)) return false;+ }+ if (q && !(c.mpn + ' ' + c.vendor + ' ' + c.package).toLowerCase().includes(q)) return false;+ if (v && c.vendor !== v) return false;+ if (s === 'full' && !c.fully_bundled) return false;+ if (s === 'stub' && c.fully_bundled) return false;+ if (s === 'no-info' && c.has_info) return false;+ if (s === 'no-pdf' && c.has_pdf) return false;+ return true;+ });+ if (filtered.length === 0) {+ cards.innerHTML = '<div class="empty-state">no chips match filters</div>';+ return;+ }+ // If there's an active chip that isn't in the filtered set, still surface it+ // (so the user always sees what's being worked on) — but make it OBVIOUS+ // it's outside the current filter, with a one-click way to clear, instead of+ // silently injecting a card that contradicts the grid behind it.+ let outOfFilterMpn = null;+ if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {+ const activeChip = LIB.chips.find(c => c.mpn === ACTIVE_MPN);+ if (activeChip) { filtered.unshift(activeChip); outOfFilterMpn = ACTIVE_MPN; }+ }+ if (outOfFilterMpn) {+ const where = ACTIVE_PROJECT_FILTER ? ('project “' + ACTIVE_PROJECT_FILTER + '”') : 'the current filter';+ const note = document.createElement('div');+ note.className = 'out-of-filter-note';+ note.innerHTML = `Showing <b>${escapeHtml(outOfFilterMpn)}</b> — not in ${escapeHtml(where)}. <button class="ofn-clear" type="button">Clear filter</button>`;+ note.querySelector('.ofn-clear').onclick = () => {+ ACTIVE_PROJECT_FILTER = null;+ document.getElementById('q').value = '';+ document.getElementById('vendor').value = '';+ document.getElementById('status').value = '';+ renderProjectTabs();+ render();+ };+ cards.appendChild(note);+ }+ for (const c of filtered) {+ const card = document.createElement('div');+ const cls = ['card'];+ if (SELECTED === c.mpn) cls.push('selected');+ if (ACTIVE_MPN === c.mpn) cls.push('active');+ card.className = cls.join(' ');+ card.dataset.mpn = c.mpn;+ const tier = srcTier(c.step_source);+ const m = (c.stock && c.stock.mouser) || null;+ // Always show stock + price badges. "?" when we don't have data.+ let stockBadge = '<span class="b stock-unk" data-tooltip="Stock level unknown.\nMouser stock data hasn\'t been fetched yet for this chip. Run a fetch to populate stock.json with current inventory + pricing.">stock ?</span>';+ let priceBadge = '<span class="b price" data-tooltip="Unit price unknown.\nMouser pricing hasn\'t been fetched yet. Run a fetch to get price breaks from Mouser\'s API.">$?</span>';+ let lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle status unknown.\nMouser hasn\'t been queried for this chip\'s lifecycle (Active / NRND / EOL / New). Run a fetch to populate.">lifecycle ?</span>';+ if (m) {+ const s = m.stock || 0;+ const cls = s >= 1000 ? 'stock-yes' : s > 0 ? 'stock-low' : 'stock-no';+ const label = s > 0 ? (s >= 1000 ? `${(s/1000).toFixed(s>=10000?0:1)}k in stock` : `${s} in stock`) : 'no stock';+ stockBadge = `<span class="b ${cls}" data-tooltip="Mouser stock: ${s} units.\n${s>=1000?'Well stocked — safe for production quantities.':s>0?'Low stock — check lead time before committing.':'Out of stock at Mouser. Check DigiKey or JLCPCB as alternatives.'}">${label}</span>`;+ priceBadge = m.unit_price+ ? `<span class="b price" data-tooltip="Mouser unit price: ${escapeHtml(m.unit_price)}.\nPrice at qty 1. Click the Mouser link in the chip detail panel for price breaks at higher quantities.">${escapeHtml(m.unit_price)}</span>`+ : '<span class="b price" data-tooltip="Price not available from Mouser for this chip.">$?</span>';+ const lc = (m.lifecycle || '').toLowerCase();+ if (/end of life|obsolete|discontinued|last.?time/.test(lc)) {+ lifecycleBadge = `<span class="b eol" data-tooltip="End of Life (${escapeHtml(m.lifecycle)}).\nThis chip is discontinued. Do NOT use in new designs — it will become unavailable. chip-fetcher will suggest active replacements if available.">EOL — DON'T USE</span>`;+ } else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) {+ lifecycleBadge = `<span class="b nrnd" data-tooltip="Not Recommended for New Designs (${escapeHtml(m.lifecycle)}).\nStill available but the manufacturer is phasing it out. Use an active replacement for new designs; OK for existing boards.">NRND — AVOID FOR NEW DESIGNS</span>`;+ } else if (/new product|new release/.test(lc)) {+ lifecycleBadge = `<span class="b new" data-tooltip="New product (${escapeHtml(m.lifecycle)}).\nRecently released by the manufacturer. Stock may be limited initially but supply is ramping.">NEW</span>`;+ } else if (lc) {+ lifecycleBadge = '';+ } else {+ lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle field empty in Mouser\'s response.\nThe chip is listed but Mouser didn\'t report Active/NRND/EOL status. Usually means Active.">lifecycle ?</span>';+ }+ }+ const pkgDisplay = (c.package && c.package !== '?' && c.package !== '—') ? c.package : '?';+ const sizeDisplay = c.size_bytes > 0 ? `${(c.size_bytes/1024/1024).toFixed(1)} MB` : '? MB';+ // Per-EDA-tool badges. Each tool gets one badge:+ // green = both symbol + footprint present+ // amber = partial (e.g. only schematic, or only footprint)+ // grey = missing entirely (shows "?")+ const ked = badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn);+ const alt = badgeForEda('ALTIUM', c.has_altium_sch, c.has_altium_pcb, c.info, c.file_provenance, c.mpn);+ const fus = badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn); // Fusion ships sym+mod in one .lbr+ // Validation badge (concur status). Read straight from inventory.rs's+ // validation_status field which we read from <MPN>-validation-status.json.+ let valBadge = '';+ if (c.validation_status === 'golden') {+ valBadge = `<span class="b val-golden" data-tooltip="Golden dataset — every source (ds2sf from datasheet, KiCad UL bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names, and package family. Safe for downstream use.">✓ GOLDEN</span>`;+ } else if (c.validation_status === 'golden_with_variants') {+ const vs = (c.validation_variants || []).map(v => v.replace(/_/g, ' ')).join(', ');+ valBadge = `<span class="b val-golden-v" data-tooltip="Golden with explainable variants.\nSources disagree on some axes, but every disagreement is benign:\n${escapeHtml(vs)}\nSafe for downstream — the variant list tells tools which source to prefer per axis.">✓ GOLDEN+${(c.validation_variants||[]).length}v</span>`;+ } else if (c.validation_status === 'divergent') {+ valBadge = `<span class="b val-divergent" data-tooltip="Divergent — at least one source disagrees and the disagreement is NOT explained by a known variant.\nClick ⟲ to re-validate, or open the concur result.json for the specific conflict axis + suggested refetch sources.">⚠ DIVERGENT</span>`;+ } else if (c.validation_status === 'unrecoverable') {+ valBadge = `<span class="b val-bad" data-tooltip="Unrecoverable — concur could not parse enough sources to run the comparison.\nCheck the chip directory for missing or corrupt files.">✗ FAIL</span>`;+ } else {+ valBadge = `<span class="b val-none" data-tooltip="Not yet validated.\nClick ⟲ to run ds2sf (extracts symbol + footprint from the datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium). Takes 30-60s per chip.">— UNVAL</span>`;+ }+ // NOTE: the datasheet-derived ("authoritative") footprint is NOT a separate+ // tile anymore — it lives as the `ds2sf · datasheet` source on the FP tile's+ // provenance carat (one tap to compare against the vendor footprint). A big+ // white SVG tile here was redundant with the FP tile and broke dark mode.+ card.innerHTML = `+ <div class="mpn-row">+ <div class="mpn">${escapeHtml(c.mpn)}</div>+ <div class="card-actions">+ <button class="card-btn" data-tooltip="Re-validate this chip.\nRuns ds2sf (extracts symbol + footprint from datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium libs). Updates the validation badge on this card when done." data-action="validate" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-validate">⟲</button>+ <button class="card-btn" data-tooltip="Re-render thumbnails.\nRegenerates the symbol SVG, footprint SVG, and all 6 orientation 3D PNGs from the current library files. Useful after importing a new STEP or .kicad_sym." data-action="thumbnails" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-render thumbnails">🖼</button>+ <select class="card-btn refetch-select" data-tooltip="Re-fetch a specific file from a different source.\nPick what you need and where to get it. Opens the source's product page in a browser window and watches for the download." data-mpn="${escapeHtml(c.mpn)}" aria-label="Refetch from source">+ <option value="">⤓ re-fetch…</option>+ <option value="manufacturer|kicad_sym">Manufacturer → Schematic symbol</option>+ <option value="manufacturer|kicad_mod">Manufacturer → PCB footprint</option>+ <option value="snapmagic|kicad_sym">SnapMagic → Schematic symbol</option>+ <option value="snapmagic|kicad_mod">SnapMagic → PCB footprint</option>+ <option value="mouser|kicad_sym">Mouser → Schematic symbol</option>+ <option value="digikey|kicad_sym">DigiKey → Schematic symbol</option>+ <option value="cse_ul|kicad_sym">Ultra Librarian → Schematic symbol</option>+ <option value="manufacturer|step">Manufacturer → 3D STEP model</option>+ <option value="manufacturer|pdf">Manufacturer → Datasheet PDF</option>+ </select>+ <button class="reveal-btn" data-tooltip="Open this chip's folder in VS Code Explorer.\nReveals library/${escapeHtml(c.mpn)}/ in the sidebar so you can see every file (STEP, KiCad sym/mod, datasheets, info.json, provenance logs)." data-reveal-mpn="${escapeHtml(c.mpn)}" aria-label="Reveal ${escapeHtml(c.mpn)} in VS Code Explorer"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button>+ <button class="card-btn zoom-btn" data-tooltip="Zoom this chip card into a centered overlay for close inspection (tap the backdrop to close). On a phone this is the way to enlarge a card — double-tap is reserved by the browser for page zoom." data-zoom-mpn="${escapeHtml(c.mpn)}" aria-label="Zoom ${escapeHtml(c.mpn)} card"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/></svg></button>+ </div>+ </div>+ <div class="meta">${escapeHtml(c.vendor || '?')} · ${escapeHtml(pkgDisplay)} · ${sizeDisplay}</div>+ ${(c.info && (c.info.summary || c.info.description)) ? `<div class="card-desc" data-tooltip="${escapeHtml(c.info.summary || c.info.description)}">${escapeHtml(c.info.summary || c.info.description)}</div>` : ''}+ <div class="thumbs">+ <!-- Primary row: the two artifacts you design with — biggest. -->+ <div class="thumb-row primary">+ ${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', null, thumbErr(c, 'sym'), c.symbol_sources)}+ ${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', null, thumbErr(c, 'fp'), c.footprint_sources)}+ </div>+ <!-- Secondary row: 3D up-axis QA + the .lbr packaging action — smaller. -->+ <div class="thumb-row secondary">+ <div class="thumb-3d-group">+ ${renderThumb(c.mpn, 'chip3d', c.has_thumb_chip3d, 'Z↑', { upAxis: 'z', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}+ ${renderThumb(c.mpn, 'chip3d-yup', c.has_thumb_chip3d_yup, 'Y↑', { upAxis: 'y', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}+ <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations.\nZ↑ and Y↑ cover 98% of chips. For unusual STEP files (WAGO connectors, through-hole parts), try X↑, X↓, Y↓, or Z↓. The chosen orientation is saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>+ <div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">+ ${['xup','xdown','ydown','zdown'].map(o => {+ const kind = 'chip3d-' + o;+ const label = o.replace('up','↑').replace('down','↓').toUpperCase().replace('X','X ').replace('Y','Y ').replace('Z','Z ');+ const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + kind + '?t=' + Date.now();+ const isChosen = c.chosen_up_axis === o;+ return `<div class="orient-opt${isChosen?' chosen':''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${label} 3D rendering as the correct up-axis for this chip.">+ <img src="${url}" onerror="this.parentElement.style.display='none'" />+ <span>${label}</span>+ </div>`;+ }).join('')}+ </div>+ </div>+ ${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — lints it for Fusion 360 / Altium / OrCAD, then one-click Send to your desktop EDA tool." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}+ </div>+ </div>+ <div class="badges">+ <span class="b ${c.fully_bundled?'full':'stub'}" data-tooltip="${c.fully_bundled?'Fully bundled — has STEP + KiCad symbol + KiCad footprint + Altium + Fusion .lbr.\nThis chip is ready for all downstream tools (chipsmith, InstaPCB, molecule creation).':'Stub — missing one or more core CAD files (STEP, KiCad sym/mod, Altium, Fusion).\nClick ⟲ to validate, or use the ⤓ dropdown to refetch missing files from a specific source.'}">${c.fully_bundled?'FULL':'STUB'}</span>+ ${valBadge}+ ${lifecycleBadge}+ ${stockBadge}+ ${priceBadge}+ <span class="b ${c.has_step?'step':'miss'}" data-tooltip="${c.has_step?'3D STEP model present.\nUsed by chipsmith for pin detection + footprint validation, and by chip-thumbnailer for the 3D orientation renders you see above.' + (c.step_source ? '\nSource: ' + c.step_source : '') + provenanceSummary(c.info, c.file_provenance, c.mpn):'3D STEP model missing.\nNeeded for chipsmith validation + 3D thumbnails.' + provenanceSummary(c.info, c.file_provenance, c.mpn)}">STEP${c.has_step?'':' ?'}</span>+ ${ked}+ ${alt}+ ${fus}+ <span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>+ <span class="b src ${tier}" data-tooltip="STEP file source: ${escapeHtml(c.step_source)}.\n${c.step_source==='—'?'No STEP source recorded yet.':'This is where the 3D model came from. The sourcing ladder (Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC) determines priority.'}">${escapeHtml(srcShort(c.step_source))}</span>+ </div>+ `;+ card.onclick = (ev) => {+ const rb = ev.target.closest('.reveal-btn');+ if (rb) { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); return; }+ // Per-card action buttons (validate / thumbnails) — POST to the+ // matching /api/<action>/<MPN> endpoint, the server spawns+ // chip-fetcher as a subprocess. Dashboard auto-refreshes from+ // /api/library polling.+ const ab = ev.target.closest('.card-btn[data-action]');+ if (ab) {+ ev.stopPropagation();+ const action = ab.dataset.action;+ const mpn = ab.dataset.mpn;+ ab.disabled = true;+ ab.style.opacity = '0.5';+ fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' })+ .then(r => r.json())+ .then(j => {+ console.log('[' + action + '] kicked:', j);+ // The subprocess heartbeats; the dashboard's activity poller+ // will pick that up + render the WORKING badge.+ })+ .catch(e => console.error('[' + action + '] failed:', e))+ .finally(() => {+ // Leave the button disabled briefly so the user gets visual+ // confirmation; re-enable after 3s. The actual work continues+ // in the background.+ setTimeout(() => { ab.disabled = false; ab.style.opacity = ''; }, 3000);+ });+ return;+ }+ const tb = ev.target.closest('.thumb[data-thumb-kind]');+ if (tb) {+ ev.stopPropagation();+ const flipTo = tb.dataset.orientFlip;+ if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); return; }+ // Symbol/footprint tiles are NOT clickable to open the app — that+ // conflicted with the source-carat menu sitting on them. Use the carat to+ // switch source and the "Open in" buttons to launch. 3D/datasheet still open.+ if (tb.dataset.thumbKind === 'symbol' || tb.dataset.thumbKind === 'footprint') return;+ openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);+ return;+ }+ // Single-click SELECTS the chip → fills the right-hand detail panel+ // (the "select a chip to see details" pane). Double-click zooms the+ // card into a centered overlay for close inspection.+ select(c.mpn);+ };+ card.ondblclick = (ev) => {+ if (ev.target.closest('.card-btn, .reveal-btn, .thumb, .refetch-select, .orient-more-btn, .orient-popover')) return;+ zoomCard(card);+ };+ // Tap-to-zoom (touch-friendly — phones reserve double-tap for page zoom).+ const zb = card.querySelector('.zoom-btn');+ if (zb) zb.onclick = (ev) => { ev.stopPropagation(); zoomCard(card); };+ // Orient-more popover toggle + pick+ const orientBtn = card.querySelector('.orient-more-btn');+ const orientPop = card.querySelector('.orient-popover');+ if (orientBtn && orientPop) {+ orientBtn.onclick = (ev) => {+ ev.stopPropagation();+ orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';+ };+ orientPop.querySelectorAll('.orient-opt').forEach(opt => {+ opt.onclick = (ev) => {+ ev.stopPropagation();+ const axis = opt.dataset.axis;+ const mpn = opt.dataset.mpn;+ orientPop.style.display = 'none';+ setOrientation(mpn, axis);+ };+ });+ }++ // Refetch dropdown — separate handler because <select> needs change+ // event, not click.+ const refSel = card.querySelector('.refetch-select');+ if (refSel) {+ refSel.onchange = (ev) => {+ ev.stopPropagation();+ const v = ev.target.value;+ if (!v) return;+ const [source, format] = v.split('|');+ const mpn = ev.target.dataset.mpn;+ ev.target.value = ''; // reset selector+ fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) ++ '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),+ { method: 'POST' })+ .then(r => r.json())+ .then(j => console.log('[refetch] kicked:', j))+ .catch(e => console.error('[refetch] failed:', e));+ };+ refSel.onclick = (ev) => ev.stopPropagation();+ }+ cards.appendChild(card);+ }+}++function autoZoomIfEnabled(mpn) {+ const cb = document.getElementById('auto-zoom-cb');+ if (!cb || !cb.checked) return;+ // Dismiss any existing zoom+ const existing = document.querySelector('.card-zoom-backdrop');+ if (existing) existing.remove();+ // Find the card and zoom it+ const card = document.querySelector(`[data-mpn="${CSS.escape(mpn)}"]`);+ if (card) {+ // Small delay so the render settles first+ setTimeout(() => zoomCard(card), 100);+ }+}++function zoomCard(cardEl) {+ // PROGRAMMATIC ANTI-STACK GUARD. pollActivity (1s), the library refresh (4s),+ // auto-zoom, and manual clicks can all call zoomCard within the same frame —+ // autoZoomIfEnabled's remove-then-setTimeout(100) loses the race when two+ // callers both pass the "none open" check before either timeout fires, so two+ // backdrops get created and stack. The only robust fix is to dedup HERE, at+ // the moment of creation: there is ever only ONE .card-zoom-backdrop. If one+ // is already open for this same chip, do nothing; otherwise replace it.+ const alreadyOpen = document.querySelector('.card-zoom-backdrop');+ if (alreadyOpen) {+ if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return;+ alreadyOpen.remove();+ }+ // Animate FROM the card's grid position TO centered on screen so+ // the user can track which card is zooming. The clone starts at+ // the card's exact rect (scale 1) and transitions to the final+ // centered + scaled position.+ const rect = cardEl.getBoundingClientRect();+ // Phone: fill the browser width (no 1.9 cap) and allow vertical scroll.+ const phone = matchMedia('(hover: none)').matches || window.innerWidth < 760;+ // The FINAL scale is computed AFTER the clone is in the DOM, from its REAL+ // height (which includes the injected Open-in/Send-to actions + status banner).+ // Computing it here from the original card height clipped those off the bottom.+ const backdrop = document.createElement('div');+ backdrop.className = 'card-zoom-backdrop';+ backdrop.dataset.mpn = cardEl.dataset.mpn || '';+ const wrapper = document.createElement('div');+ wrapper.className = 'card-zoomed-wrapper';+ const clone = cardEl.cloneNode(true);+ clone.style.width = rect.width + 'px';+ clone.style.transformOrigin = 'top left';+ // Inject Open-in + Send-to into the zoom focus view so the chip's actions are+ // reachable here (especially on a phone, where the side panel is cramped).+ // The .openbtn/.sendbtn handlers are delegated on document, so they just work.+ const zchip = (LIB && LIB.chips || []).find(x => x.mpn === cardEl.dataset.mpn);+ if (zchip) {+ const open = [+ zchip.has_sym && ['symbol', 'adom-symbol'],+ zchip.has_mod && ['footprint', 'adom-footprint'],+ (zchip.has_sym || zchip.has_mod) && ['lbr', 'adom-lbr'],+ zchip.has_step && ['chip3d', 'adom-3D'],+ zchip.has_step && ['chipsmith', 'chipsmith'],+ (zchip.symbol_sources || []).some(s => s.method === 'ds2sf') && ['ds2sf', 'ds2sf extraction'],+ ].filter(Boolean);+ const sec = document.createElement('div');+ sec.className = 'zoom-actions';+ sec.innerHTML =+ `<div class="zoom-sec">Open in</div><div class="openin-row" data-mpn="${escapeHtml(zchip.mpn)}">`+ + open.map(([k, l]) => `<button class="openbtn" data-kind="${k}">${l}</button>`).join('')+ + `</div><div class="zoom-sec">Send to</div><div class="sendto-row" data-mpn="${escapeHtml(zchip.mpn)}">`+ + [['kicad', 'KiCad'], ['fusion360', 'Fusion'], ['altium', 'Altium'], ['orcad', 'OrCAD']]+ .map(([t, l]) => `<button class="sendbtn" data-target="${t}">${l}</button>`).join('')+ + `</div>`;+ clone.appendChild(sec);+ }+ // Fluid 2-column layout (desktop): move the media (.thumbs) into a left column+ // and everything else into a right column, so the zoom uses the horizontal room+ // and is short enough to scale up big. Children stay descendants of the clone,+ // so the click re-wiring below still finds them.+ if (!phone) {+ const media = document.createElement('div'); media.className = 'zoom-col-media';+ const info = document.createElement('div'); info.className = 'zoom-col-info';+ Array.from(clone.children).forEach(ch => {+ (ch.classList.contains('thumbs') ? media : info).appendChild(ch);+ });+ clone.appendChild(media);+ clone.appendChild(info);+ clone.classList.add('zoom-fluid');+ clone.style.width = Math.min(900, Math.round(window.innerWidth * 0.82)) + 'px';+ }+ // Start at the card's grid position (scale 1)+ wrapper.style.left = rect.left + 'px';+ wrapper.style.top = rect.top + 'px';+ clone.style.transform = 'scale(1)';+ // Show a "working on" banner ONLY while THIS chip is actively being+ // fetched. ACTIVE_MPN/ACTIVE_STAGE are maintained by pollActivity and are+ // null when idle/stale — so an idle chip shows no banner at all (it used to+ // inject an empty "working on" box, which read as broken).+ const zoomMpn = cardEl.dataset.mpn;+ function syncZoomStatus() {+ const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;+ // The status banner floats on the BACKDROP (fixed, top-center) — NOT inside+ // the card. Injecting it into the card reflowed + rescaled the whole layout+ // every time it appeared/disappeared, which read as "wacky". As an overlay+ // pill it never touches the card's geometry; the liked layout stays put.+ let bar = backdrop.querySelector('.zoom-status');+ if (working) {+ if (!bar) {+ bar = document.createElement('div');+ bar.className = 'zoom-status';+ bar.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:20;background:rgba(0,184,176,.18);border:1px solid var(--accent);border-radius:999px;padding:8px 18px;font-size:13px;font-weight:600;color:var(--accent);font-family:Satoshi,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,.45);backdrop-filter:blur(6px);white-space:nowrap;';+ backdrop.appendChild(bar);+ }+ bar.textContent = '● working on — ' + ACTIVE_STAGE;+ } else if (bar) {+ bar.remove();+ }+ }+ syncZoomStatus();+ const statusPoll = setInterval(syncZoomStatus, 1500);+ wrapper.appendChild(clone);+ backdrop.appendChild(wrapper);+ // Dismiss with reverse animation (declared before use by the X button).+ function dismiss() {+ clearInterval(statusPoll);+ backdrop.classList.remove('visible');+ if (!phone) { wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)'; }+ setTimeout(() => backdrop.remove(), 300);+ document.removeEventListener('keydown', escHandler);+ }+ // Always-visible close (X) so it's obvious how to get back to the index.+ const xbtn = document.createElement('button');+ xbtn.className = 'card-zoom-x'; xbtn.setAttribute('aria-label', 'Close'); xbtn.textContent = '✕';+ xbtn.onclick = (e) => { e.stopPropagation(); dismiss(); };+ backdrop.appendChild(xbtn);+ document.body.appendChild(backdrop);+ // Fit the WHOLE card — including the injected Open-in/Send-to actions + any+ // status banner — on screen with ONE zoom factor: the largest scale that fits+ // BOTH the width and the card's REAL height. So it fills as much of the room as+ // it can, is always fully visible, and NEVER scrolls or clips. (Measure the+ // clone's natural height now that it's in the DOM at scale 1.)+ // Fit the card to the viewport. CRITICAL: measure the clone's size AFTER the+ // 2-column fluid layout + thumbnails have settled. Measuring synchronously+ // here reads the TALL single-column height (before the grid reflows to wide-+ // and-short), so the scale comes out too small and the card ends up a cramped+ // little box floating in empty space — exactly the "weird at this zoom" bug.+ function fitZoom() {+ const naturalW = clone.offsetWidth || rect.width;+ const naturalH = clone.offsetHeight || rect.height;+ const scale = Math.min(+ (window.innerWidth * (phone ? 0.96 : 0.94)) / naturalW,+ (window.innerHeight * 0.94) / naturalH+ );+ const scaledW = naturalW * scale, scaledH = naturalH * scale;+ wrapper.style.left = Math.max(8, (window.innerWidth - scaledW) / 2) + 'px';+ wrapper.style.top = Math.max(8, (window.innerHeight - scaledH) / 2) + 'px';+ clone.style.transition = 'transform .3s ease-out';+ clone.style.transform = `scale(${scale})`;+ }+ backdrop.classList.add('visible');+ // Double-rAF so the fluid 2-col layout has reflowed before we measure…+ requestAnimationFrame(() => requestAnimationFrame(fitZoom));+ // …and re-fit when a late-loading thumbnail changes the card's height.+ clone.querySelectorAll('img').forEach(img => {+ if (!img.complete) img.addEventListener('load', fitZoom, { once: true });+ });+ // Re-fit on browser zoom / window resize so it never goes weird at non-100%.+ const onResize = () => fitZoom();+ window.addEventListener('resize', onResize);+ backdrop.addEventListener('remove', () => window.removeEventListener('resize', onResize));+ backdrop.onclick = (ev) => { if (ev.target === backdrop) dismiss(); };+ const escHandler = (ev) => { if (ev.key === 'Escape') dismiss(); };+ document.addEventListener('keydown', escHandler);+ // Re-wire ALL interactive elements inside the zoom clone.+ // The clone is a dead DOM copy — no event listeners survived.+ // Every clickable thing must be re-attached here.++ // 1. Thumbnail clicks: open viewer OR flip orientation+ clone.querySelectorAll('.thumb[data-thumb-kind]').forEach(tb => {+ tb.onclick = (ev) => {+ ev.stopPropagation();+ const flipTo = tb.dataset.orientFlip;+ if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); dismiss(); return; }+ openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);+ };+ tb.style.cursor = 'pointer';+ });++ // 2. Orient-more popover (▾ button + option tiles)+ const orientBtn = clone.querySelector('.orient-more-btn');+ const orientPop = clone.querySelector('.orient-popover');+ if (orientBtn && orientPop) {+ orientBtn.onclick = (ev) => {+ ev.stopPropagation();+ orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';+ };+ clone.querySelectorAll('.orient-opt').forEach(opt => {+ opt.onclick = (ev) => {+ ev.stopPropagation();+ setOrientation(opt.dataset.mpn, opt.dataset.axis);+ dismiss();+ };+ });+ }++ // 3. Action buttons (⟲ validate, 🖼 thumbnails)+ clone.querySelectorAll('.card-btn[data-action]').forEach(ab => {+ ab.onclick = (ev) => {+ ev.stopPropagation();+ fetch(API_BASE + '/api/' + ab.dataset.action + '/' + encodeURIComponent(ab.dataset.mpn), { method: 'POST' });+ };+ });++ // 4. Refetch dropdown+ const refSel = clone.querySelector('.refetch-select');+ if (refSel) {+ refSel.onchange = (ev) => {+ ev.stopPropagation();+ const v = ev.target.value;+ if (!v) return;+ const [source, format] = v.split('|');+ const mpn = ev.target.dataset.mpn;+ ev.target.value = '';+ fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) ++ '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),+ { method: 'POST' });+ };+ refSel.onclick = (ev) => ev.stopPropagation();+ }++ // 5. Reveal folder button+ clone.querySelectorAll('.reveal-btn[data-reveal-mpn]').forEach(rb => {+ rb.onclick = (ev) => { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); };+ });++ // 6. PDF badge click (open datasheet)+ clone.querySelectorAll('[data-thumb-mpn][data-thumb-kind="datasheet"]').forEach(pdf => {+ pdf.onclick = (ev) => { ev.stopPropagation(); openThumb(pdf.dataset.thumbMpn, 'datasheet'); };+ pdf.style.cursor = 'pointer';+ });+}++function escapeHtml(s) {+ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);+}++// One thumbnail tile. Clickable when the source file is present —+// click dispatches to the right Adom app (3D → adom-step, PDF →+// adom-pdf-viewer, sym/fp → full-size SVG in a pup tab). Empty slot+// shows a dashed placeholder so the user can see what's coming.+// Returns the chip-thumbnailer failure reason for a thumbnail kind, or null.+// Keys in .thumb-errors.json are "sym" / "fp" / "3d".+function thumbErr(c, key) {+ const e = c && c.thumb_errors;+ return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null;+}++function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {+ if (!present) {+ // Distinguish a FAILED render (chip-thumbnailer errored) from one that was+ // never attempted — a blank tile next to a green KICAD/STEP chip otherwise+ // reads as "no file", hiding a real generation failure.+ if (errMsg) {+ return `<div class="thumb failed" data-tooltip="${escapeHtml(label)} render failed: ${escapeHtml(errMsg)}\nClick the ⟲ / 🖼 card buttons to re-validate or re-render."><span class="thumb-failed">⚠ ${escapeHtml(label)}</span><span class="thumb-failed-sub">render failed</span></div>`;+ }+ return `<div class="thumb empty" data-tooltip="${escapeHtml(label)} thumbnail not yet generated.\nUse the 🖼 card button to render it from the current library files."><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`;+ }+ // Show the ACTIVE source's thumbnail on the main tile: the canonical render+ // for the current source, or the `.<method>` variant when the user picked one.+ let methodSuffix = '';+ if (sources && sources.length) {+ const am = activeMethod(mpn, kind, sources);+ const amS = sources.find(s => s.method === am);+ if (amS && !amS.current && amS.thumb) methodSuffix = '.' + am;+ }+ const url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + methodSuffix;+ const src = url + '?t=' + Date.now();++ // Orientation-picker affordances on the two 3D tiles. When `orient` is+ // passed, decorate active vs inactive: active gets a green border + corner+ // check, inactive dims to 55% until hover with a "click to mark this+ // orientation as correct" hint in the tooltip.+ let orientAttrs = '';+ let orientCheck = '';+ let titleSuffix = '';+ if (orient && (kind === 'chip3d' || kind === 'chip3d-yup')) {+ const isActive = orient.chosen === orient.upAxis;+ if (isActive) {+ orientAttrs = ' data-orient-active="true"';+ orientCheck = `<span class="orient-check" aria-label="active orientation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;+ const inferredMatches = orient.inferred === orient.upAxis;+ titleSuffix = inferredMatches+ ? ` · active (auto-detected ${orient.inferred.toUpperCase()}-up)`+ : ` · active (you picked, overriding inferred ${orient.inferred.toUpperCase()}-up)`;+ } else {+ orientAttrs = ` data-orient-inactive="true" data-orient-flip="${orient.upAxis}"`;+ titleSuffix = ` · click to mark this ${orient.upAxis.toUpperCase()}-up rendering as correct`;+ }+ }++ const openTip = {+ symbol: `Open ${mpn} in the live adom-symbol app — interactive schematic symbol rendered via service-kicad (no local KiCad).`,+ footprint: `Open ${mpn} in the live adom-footprint app — interactive PCB footprint rendered via service-kicad (no local KiCad).`,+ chip3d: `Open ${mpn} in the adom-step 3D viewer — orbit the real STEP model.`,+ 'chip3d-yup': `Open the Y-up 3D render of ${mpn} full-size (Fusion 360 / SolidWorks convention).`,+ datasheet: `Open the ${mpn} datasheet PDF in the viewer.`,+ }[kind] || `Open ${mpn} ${label} in Adom.`;+ // If a per-source variant thumbnail is missing/failed, fall back to the+ // canonical render (no broken image). Only one fallback hop.+ const canonUrl = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind;+ const onerr = methodSuffix+ ? `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${canonUrl}?t='+Date.now()}else{this.style.visibility='hidden'}"`+ : `onerror="this.style.visibility='hidden'"`;+ // Symbol/footprint tiles are NON-interactive (no open-on-click, no hover+ // tooltip) so they don't fight the source-carat menu sitting on them.+ const noHover = (kind === 'symbol' || kind === 'footprint');+ const tipAttr = noHover ? '' : ` data-tooltip="${escapeHtml(openTip + titleSuffix)}"`;+ const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>+ <img src="${src}" alt="${escapeHtml(mpn)} ${escapeHtml(label)}" loading="lazy" ${onerr}>+ <span class="thumb-label">${escapeHtml(label)}</span>+ ${orientCheck}+ </div>`;+ // Symbol/footprint provenance carat: an always-visible, tappable source chip+ // (works on touch) + a popover listing every method that produced a variant.+ if ((kind === 'symbol' || kind === 'footprint') && sources && sources.length) {+ return `<div class="thumb-src-group">${thumbHtml}${srcCarat(mpn, kind, sources)}</div>`;+ }+ return thumbHtml;+}++// Always-visible tappable source chip + popover of all source methods.+function srcCarat(mpn, kind, sources) {+ if (!sources.length) return '';+ const active = activeMethod(mpn, kind, sources);+ const cur = sources.find(s => s.method === active) || sources.find(s => s.current) || sources[0];+ const multi = sources.length > 1;+ const opts = sources.map(s => {+ const tsrc = s.thumb ? `${API_BASE}/thumb/${encodeURIComponent(mpn)}/${kind}${s.current ? '' : '.' + s.method}?t=${Date.now()}` : '';+ const sel = s.method === active;+ return `<div class="src-opt${sel ? ' chosen' : ''}" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}"+ data-tooltip="${escapeHtml(s.label)} — ${sel ? 'the variant shown now.' : 'tap to make this the source shown on the tile + open it in adom-' + kind + '.'}">+ ${s.thumb ? `<img class="src-opt-thumb" src="${tsrc}" loading="lazy" onerror="this.outerHTML='<span class=\\'src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}\\'></span>'">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}+ <span class="src-opt-lab">${escapeHtml(s.label)}</span>${sel ? '<span class="src-cur">●</span>' : ''}+ </div>`;+ }).join('');+ return `<button class="src-chip" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}"+ data-tooltip="Source of this ${kind}: ${escapeHtml(cur.label)}.${multi ? ' ' + sources.length + ' methods — tap to switch.' : ' Tap for details.'}">+ <span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(cur.label)}</span>${multi ? '<span class="src-chip-car">▾</span>' : ''}+ </button>+ <div class="src-popover" data-mpn="${escapeHtml(mpn)}" style="display:none">${opts}</div>`;+}++async function setOrientation(mpn, axis) {+ try {+ const r = await fetch(API_BASE + '/api/orientation', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn, axis}),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) {+ alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));+ return;+ }+ const libR = await fetch(API_BASE + '/api/library');+ LIB = await libR.json();+ LAST_LIB_HASH = '';+ render();+ } catch (e) {+ alert('Could not reach /api/orientation: ' + e.message);+ }+}++// Bring the active card into view. Called whenever the heartbeat MPN+// changes — cards take more vertical real estate now that thumbnails+// land below the meta row, so they scroll off screen easily during a+// long batch. Only scroll if the card is genuinely off-screen so we+// don't fight the user's manual scroll while they're inspecting.+function scrollActiveCardIntoView() {+ const card = document.querySelector('.card.active') || (ACTIVE_MPN && document.querySelector(`.card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));+ if (!card) return;+ const rect = card.getBoundingClientRect();+ const fullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;+ if (fullyVisible) return;+ card.scrollIntoView({behavior: 'smooth', block: 'center'});+}++// Provenance carat: toggle the popover, or open a source variant.+document.addEventListener('click', (ev) => {+ const chip = ev.target.closest('.src-chip');+ if (chip) {+ ev.stopPropagation(); ev.preventDefault();+ const pop = chip.parentElement.querySelector('.src-popover');+ document.querySelectorAll('.src-popover').forEach(p => { if (p !== pop) p.style.display = 'none'; });+ if (pop) pop.style.display = pop.style.display === 'none' ? 'flex' : 'none';+ return;+ }+ const opt = ev.target.closest('.src-opt');+ if (opt) {+ ev.stopPropagation();+ const mpn = opt.dataset.mpn, kind = opt.dataset.kind, method = opt.dataset.method;+ const canonical = opt.dataset.canonical === '1';+ // Persist the pick so the carat + tile show this source (sticks across reloads).+ SRC_SEL[mpn + '|' + kind] = method; saveSrcSel();+ // Update the DOM in place: chip label, chosen highlight, main tile thumbnail.+ const group = opt.closest('.thumb-src-group');+ if (group) {+ const label = opt.querySelector('.src-opt-lab').textContent;+ const dotCls = method === 'ds2sf' ? 'ds' : 'vendor';+ const chipLab = group.querySelector('.src-chip-lab'); if (chipLab) chipLab.textContent = label;+ const chipDot = group.querySelector('.src-chip .src-dot'); if (chipDot) chipDot.className = 'src-dot ' + dotCls;+ group.querySelectorAll('.src-opt').forEach(o => {+ const on = o === opt;+ o.classList.toggle('chosen', on);+ let bullet = o.querySelector('.src-cur');+ if (on && !bullet) { bullet = document.createElement('span'); bullet.className = 'src-cur'; bullet.textContent = '●'; o.appendChild(bullet); }+ else if (!on && bullet) bullet.remove();+ });+ const img = group.querySelector('.thumb img');+ if (img) img.src = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + (canonical ? '' : '.' + method) + '?t=' + Date.now();+ }+ const pop = opt.closest('.src-popover'); if (pop) pop.style.display = 'none';+ // Switching the source ONLY swaps the tile thumbnail — it must NOT open the+ // app (clicking the menu kept launching adom-symbol). Use the explicit+ // "Open in" buttons to launch an app.+ return;+ }+ document.querySelectorAll('.src-popover').forEach(p => { if (!p.contains(ev.target)) p.style.display = 'none'; });+});++// Lightweight toast (bottom-center). Optional href makes it a tappable link.+let CF_TOAST_T = null;+function cfToast(msg, href) {+ let t = document.getElementById('cf-toast');+ if (!t) { t = document.createElement('div'); t.id = 'cf-toast'; document.body.appendChild(t); }+ t.innerHTML = href ? `${msg} <a href="${href}" target="_blank" rel="noopener">open ↗</a>` : msg;+ t.classList.add('show'); clearTimeout(CF_TOAST_T);+ CF_TOAST_T = setTimeout(() => t.classList.remove('show'), href ? 8000 : 4200);+}++// The viewing context, for choosing + NAMING the open method.+function viewContext() {+ const embedded = window.self !== window.top; // a Hydrogen webview is an iframe+ const phone = matchMedia('(hover: none)').matches || innerWidth < 760;+ return { embedded, phone };+}++// Open a chip in an Adom app. Opens in the user's CURRENT browser via+// window.open (works on a phone, in pup, or a Hydrogen webview) — the only+// universally-correct surface — and tells the user exactly how it opened.+async function openThumb(mpn, kind, source) {+ const APP = { symbol: 'adom-symbol', footprint: 'adom-footprint', lbr: 'adom-lbr',+ chip3d: 'adom-3D', 'chip3d-yup': 'adom-3D', datasheet: 'the datasheet', chipsmith: 'chipsmith', ds2sf: 'adom-symbol (ds2sf)' };+ const app = APP[kind] || kind;+ // Pre-open a blank tab INSIDE the click gesture so the browser doesn't block+ // it; navigate it once the server has the chip loaded. (chipsmith has no URL —+ // it opens its own Hydrogen webview server-side — so don't pre-open for it.)+ const tab = kind === 'chipsmith' ? null : window.open('', '_blank');+ try {+ const r = await fetch(API_BASE + '/api/open', {+ method: 'POST', headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ mpn, kind, source }),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast(`⚠ Couldn't open ${app}: ${j.error || ('HTTP ' + r.status)}`); return; }+ if (kind === 'chipsmith') {+ // chipsmith opens its own Hydrogen webview (on the machine running Adom+ // Desktop / Hydrogen) — not a browser tab, so it won't appear on a phone.+ cfToast(`Opened ${app} as a Hydrogen webview${j.desktopBridge ? ' on your Adom Desktop' : ' (needs Hydrogen — not visible on a phone)'}.`);+ return;+ }+ const { embedded, phone } = viewContext();+ const where = phone ? 'a new browser tab on your phone' : embedded ? 'a new tab from the embedded view' : 'a new browser tab';+ if (tab && j.url) { tab.location.href = j.url; cfToast(`Opened ${app} in ${where} (window.open).`); }+ else if (j.url) { cfToast(`${app} is ready — pop-up was blocked, tap to open:`, j.url); }+ } catch (e) { if (tab) tab.close(); cfToast(`⚠ Couldn't reach the server: ${e.message}`); }+}++// Build per-file provenance lines from file_provenance JSON.+// Returns e.g. ".step from snapmagic\n.kicad_mod from snapmagic"+function fileProvenanceLines(fileProv, mpn) {+ if (!fileProv || typeof fileProv !== 'object') return '';+ var lines = [];+ for (var filename in fileProv) {+ var entry = fileProv[filename];+ if (!entry) continue;+ var ext = filename.replace(mpn + '.', '.').replace(mpn, '');+ var src = entry.discovery_source || entry.content_origin || '?';+ var origin = entry.content_origin || '';+ if (origin && origin !== src) src += ' (made by ' + origin + ')';+ lines.push(ext + ' from ' + src);+ }+ return lines.length ? '\nProvenance:\n' + lines.join('\n') : '';+}++// Summarize the sourcing chain from info.json into a one-line provenance string.+// Fallback when per-file provenance isn't available.+function provenanceSummary(info, fileProv, mpn) {+ // Prefer per-file provenance when available+ var perFile = fileProvenanceLines(fileProv, mpn);+ if (perFile) return perFile;+ // Fall back to chain summary+ if (!info || !info.fetched_via_chain || !info.fetched_via_chain.length) return '';+ var sources = info.fetched_via_chain.map(function(e) {+ var s = e.source || '?';+ var o = e.outcome || '';+ if (o === 'attempted' || o === 'failed') s += ' (tried)';+ if (o === 'success') s += ' (got it)';+ return s;+ });+ return '\nSourced via: ' + sources.join(' > ');+}++// One badge per EDA tool: green if both halves present, amber if partial,+// grey "?" if missing. Fusion uses one .lbr containing both sym+mod, so+// callers pass the same flag twice for that tool.+function badgeForEda(label, hasSym, hasMod, info, fileProv, mpn) {+ const fullName = {KICAD:'KiCad',ALTIUM:'Altium',FUSION:'Fusion 360'}[label]||label;+ var prov = provenanceSummary(info, fileProv, mpn);+ if (hasSym && hasMod) {+ return `<span class="b ${label.toLowerCase()}" data-tooltip="${fullName} schematic symbol + PCB footprint both present.\nThis chip has a complete ${fullName} library — ready for import into your EDA tool.${prov}">${label}</span>`;+ }+ if (hasSym || hasMod) {+ const has = hasSym ? 'schematic symbol' : 'PCB footprint';+ const miss = hasSym ? 'PCB footprint' : 'schematic symbol';+ return `<span class="b partial-eda" data-tooltip="${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.${prov}">${label} ${hasSym?'sym':'fp'}</span>`;+ }+ return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.${prov}">${label} ?</span>`;+}++async function select(mpn) {+ SELECTED = mpn;+ updateSubject();+ document.querySelectorAll('.card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));+ const r = await fetch(API_BASE + '/api/chip/' + encodeURIComponent(mpn));+ if (!r.ok) {+ document.getElementById('detail').innerHTML = '<div class="empty-state">failed to load</div>';+ return;+ }+ const c = await r.json();+ const aside = document.getElementById('detail');+ aside.classList.remove('empty');++ // Render every canonical info.json field. "?" placeholder when unknown so+ // the user sees explicitly what we don't have, not a missing row.+ const CANONICAL = [+ ['manufacturer', 'Manufacturer'],+ ['package', 'Package'],+ ['family', 'Family'],+ ['description', 'Description'],+ ['step_source', 'STEP source'],+ ['step_authoring', 'STEP authoring'],+ ['step_url', 'STEP URL'],+ ['step_note', 'STEP note'],+ ['datasheet_source','Datasheet source'],+ ['datasheet_url', 'Datasheet URL'],+ ['datasheet_note', 'Datasheet note'],+ ['sym_source', 'Symbol source'],+ ['sym_url', 'Symbol URL'],+ ['mod_source', 'Footprint source'],+ ['mod_url', 'Footprint URL'],+ ['ul_deeplink_source', 'UL deep-link source'],+ ['ul_deeplink_url', 'UL deep-link URL'],+ ['ul_deeplink_note', 'UL deep-link note'],+ ['ul_zip', 'UL zip'],+ ['fetched_at', 'Fetched at'],+ ['verified_by', 'Verified by'],+ ];+ const info = c.info || {};+ const fieldRows = CANONICAL.map(([k, label]) => {+ let v = info[k];+ if (v === undefined || v === null || v === '') v = '?';+ const isQ = (v === '?');+ const isUrl = typeof v === 'string' && /^https?:\/\//.test(v);+ const valHtml = isUrl+ ? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>`+ : escapeHtml(String(v));+ return `<div class="field ${isQ?'unknown':''}">+ <span class="field-key">${escapeHtml(label)}</span>+ <span class="field-val">${valHtml}</span>+ </div>`;+ }).join('');++ const tab = DETAIL_TAB || 'actions';+ aside.innerHTML = `+ <h2>${escapeHtml(c.mpn)}</h2>+ <div class="vendor">${escapeHtml(c.vendor || '?')} · ${escapeHtml(c.package || '?')}${c.family && c.family !== '?' ? ' · ' + escapeHtml(c.family) : ''}</div>++ <div class="dtabs" role="tablist">+ <button class="dtab${tab==='actions'?' on':''}" data-tab="actions">Actions</button>+ <button class="dtab${tab==='details'?' on':''}" data-tab="details">Details</button>+ <button class="dtab${tab==='files'?' on':''}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button>+ </div>++ <div class="dpanel${tab==='actions'?' on':''}" data-panel="actions">+ <div class="section">+ <div class="section-title">Open in</div>+ <div class="openin-row" data-mpn="${escapeHtml(c.mpn)}">+ ${c.has_sym ? `<button class="openbtn" data-kind="symbol" data-tooltip="Open ${escapeHtml(c.mpn)}'s schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}+ ${c.has_mod ? `<button class="openbtn" data-kind="footprint" data-tooltip="Open ${escapeHtml(c.mpn)}'s PCB footprint in the live adom-footprint app (layers + InstaPCB solder-paste dots).">adom-footprint</button>` : ''}+ ${(c.has_sym || c.has_mod) ? `<button class="openbtn" data-kind="lbr" data-tooltip="Build + open a true EAGLE .lbr (symbol + footprint) in adom-lbr.">adom-lbr</button>` : ''}+ ${c.has_step ? `<button class="openbtn" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}+ ${c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith — STEP-native pin/footprint detection + sign-off.">chipsmith</button>` : ''}+ ${(c.symbol_sources||[]).some(s=>s.method==='ds2sf') ? `<button class="openbtn" data-kind="ds2sf" data-tooltip="View ds2sf's datasheet-extracted symbol (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}+ </div>+ </div>+ <div class="section">+ <div class="section-title">Send to</div>+ <div class="sendto-row" data-mpn="${escapeHtml(c.mpn)}">+ <button class="sendbtn" data-target="kicad" data-tooltip="Install the native KiCad symbol + footprint into your KiCad libraries via Adom Desktop.">KiCad</button>+ <button class="sendbtn" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>+ <button class="sendbtn" data-target="altium" data-tooltip="Build the EAGLE .lbr + import steps (native Altium exporter on the roadmap).">Altium</button>+ <button class="sendbtn" data-target="orcad" data-tooltip="Build the EAGLE .lbr (native OrCAD exporter on the roadmap).">OrCAD</button>+ </div>+ </div>+ <div class="section">+ <div class="section-title">Status</div>+ <div class="badges">+ <span class="b ${c.fully_bundled?'full':'stub'}">${c.fully_bundled?'FULL':'STUB'}</span>+ <span class="b ${c.has_step?'step':'miss'}">STEP${c.has_step?'':' ?'}</span>+ ${badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn)}+ ${badgeForEda('ALTIUM', c.has_altium, c.has_altium, c.info, c.file_provenance, c.mpn)}+ ${badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn)}+ <span class="b ${c.pdf_count>0?'pdf':'miss'}">${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>+ <span class="b src ${srcTier(c.step_source)}">source: ${escapeHtml(srcShort(c.step_source))}</span>+ </div>+ </div>+ </div>++ <div class="dpanel${tab==='details'?' on':''}" data-panel="details">+ <div class="fields">${fieldRows}</div>+ </div>++ <div class="dpanel${tab==='files'?' on':''}" data-panel="files">+ <ul class="filelist">+ ${c.files.map(f => {+ const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f);+ return `<li><a href="${url}" target="_blank">${escapeHtml(f)}</a></li>`;+ }).join('')}+ </ul>+ </div>+ `;+ aside.querySelectorAll('.dtab').forEach(t => t.onclick = () => {+ DETAIL_TAB = t.dataset.tab;+ aside.querySelectorAll('.dtab').forEach(x => x.classList.toggle('on', x === t));+ aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB));+ });+}++document.getElementById('q').addEventListener('input', render);+document.getElementById('vendor').addEventListener('change', render);+document.getElementById('status').addEventListener('change', render);++// ---------------- open a chip in a specific Adom tool ----------------+// Delegated on document so it works in the detail panel AND the zoom overlay.+document.addEventListener('click', (ev) => {+ const b = ev.target.closest('.openbtn');+ if (!b) return;+ const mpn = b.closest('.openin-row').dataset.mpn, kind = b.dataset.kind;+ const prev = b.textContent; b.disabled = true; b.textContent = 'Opening…';+ setTimeout(() => { b.disabled = false; b.textContent = prev; }, 2500);+ // ds2sf has no viewer of its own — open its extracted symbol on the ds2sf source.+ if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf');+ else openThumb(mpn, kind);+});++// ---------------- send to desktop EDA tools ----------------+// Delegated on document so it works in the detail panel AND the zoom overlay.+document.addEventListener('click', async (ev) => {+ const b = ev.target.closest('.sendbtn');+ if (!b) return;+ const row = b.closest('.sendto-row');+ const mpn = row.dataset.mpn, target = b.dataset.target, label = b.textContent;+ const all = [...row.querySelectorAll('.sendbtn')];+ all.forEach(x => x.disabled = true);+ const prev = b.textContent; b.textContent = 'Sending…';+ try {+ const r = await fetch(API_BASE + '/api/deliver', {+ method: 'POST', headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn, target}),+ });+ const j = await r.json().catch(() => ({}));+ if (j.needsBridge) showBridgeModal(j);+ else if (j.ok) b.textContent = '✓ ' + label;+ else alert((j.message || j.error || 'Could not send to ' + label));+ } catch (e) { alert('Send failed: ' + e.message); }+ finally { setTimeout(() => { all.forEach(x => x.disabled = false); b.textContent = prev; }, 2500); }+});+function showBridgeModal(j) {+ let m = document.getElementById('bridgeModal');+ if (!m) { m = document.createElement('div'); m.id = 'bridgeModal'; document.body.appendChild(m); }+ const dl = j.assetUrl+ ? `<a class="bm-go" href="${API_BASE + j.assetUrl}" download>⬇ Download the .lbr</a>` : '';+ const hint = j.importHint ? `<div class="bm-hint">${j.importHint}</div>` : '';+ const icon = j.exportWorks ? '📦' : '🌉';+ m.innerHTML = '<div class="bm-card"><div class="bm-icon">' + icon + '</div>'+ + '<div class="bm-title">' + (j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge')) + '</div>'+ + '<div class="bm-body">' + (j.message || '') + '</div>'+ + hint+ + '<div class="bm-actions">' + dl+ + '<a class="bm-contribute" href="' + (j.contribute || 'https://wiki.adom.inc') + '" target="_blank" rel="noopener">Build the auto-import bridge — contribute on the wiki →</a>'+ + '<button class="bm-dismiss">Close</button></div></div>';+ m.classList.add('show');+ m.querySelector('.bm-dismiss').onclick = () => m.classList.remove('show');+ m.onclick = e => { if (e.target === m) m.classList.remove('show'); };+}++// ---------------- reveal folder in VS Code Explorer ----------------+async function revealFolder(mpn) {+ try {+ const r = await fetch(API_BASE + '/api/reveal', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn: mpn || ''}),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) {+ console.warn('reveal failed', j);+ alert('Could not reveal folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)));+ }+ } catch (e) {+ console.warn('reveal request failed', e);+ alert('Could not reach /api/reveal: ' + e.message);+ }+}+document.getElementById('btn-reveal-lib').addEventListener('click', () => revealFolder(''));++// ---------------- VALIDATION tab — brutal self-audit ----------------+const REQ = [+ ['datasheet_pdf','Datasheet'], ['step_3d','STEP 3D'],+ ['kicad_symbol','Symbol'], ['kicad_footprint','Footprint'],+ ['thumb_symbol','Sym thumb'], ['thumb_footprint','FP thumb'], ['thumb_3d','3D thumb'],+];+let validationOn = false, validationTimer = null;+function setValidationView(on) {+ validationOn = on;+ document.getElementById('grid-view').style.display = on ? 'none' : '';+ document.getElementById('validation-view').style.display = on ? 'block' : 'none';+ document.getElementById('btn-validate').style.background = on ? 'var(--accent)' : '';+ document.getElementById('btn-validate').style.color = on ? '#fff' : '';+ if (on) { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }+ else if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }+}+async function renderValidation() {+ const host = document.getElementById('validation-view');+ let data;+ try { data = await (await fetch(API_BASE + '/api/validate?v=' + Date.now())).json(); }+ catch (e) { host.innerHTML = '<div class="empty-state">validation API error</div>'; return; }+ const s = data.summary || {};+ const allGreen = s.total > 0 && s.incomplete === 0;+ const cell = (ok) => `<td style="text-align:center;font-size:16px;font-weight:700;color:${ok ? 'var(--green,#34d17e)' : '#ff5252'}">${ok ? '✓' : '✗'}</td>`;+ // adom-concur cross-source consensus cell: did KiCad / EAGLE / Altium / ds2sf agree?+ const consensusCell = (con) => {+ if (!con) return `<td style="text-align:center;font-size:11px;color:var(--muted)">— not run</td>`;+ const golden = /golden/i.test(con.status || '');+ const col = golden ? 'var(--green,#34d17e)' : '#ff5252';+ const label = golden ? (con.status === 'golden' ? 'GOLDEN' : 'GOLDEN*') : (con.status || '?').toUpperCase();+ const srcs = (con.sources || []).join(' · ') || '—';+ return `<td style="text-align:center;white-space:nowrap" data-tooltip="adom-concur cross-source consensus.\nCompared: ${srcs}\nMissing: ${(con.missing||[]).join(', ')||'none'}\n${con.agree} axes agree, ${con.disagree} disagree of ${con.total_axes}. GOLDEN* = agree except a naming-convention variant.">+ <span style="font-weight:700;color:${col};font-size:13px">${label}</span>+ <span style="font-size:10px;color:var(--muted);display:block">${con.agree}✓/${con.disagree}✗ · ${(con.sources||[]).length} src</span></td>`;+ };+ // Per-source child row: which sym / fp / 3d we generated from THIS library.+ // 3D with an asterisk = the library shipped no STEP, so we used the global+ // chip 3D (hover explains). Normal check = the library had its own 3D.+ const miniChk = (ok, why) => `<span${(!ok && why) ? ` data-tooltip="${escapeHtml(why)}" style="cursor:help;` : ` style="`}color:${ok?'var(--green,#34d17e)':'#ff5252'};font-weight:700;font-size:13px">${ok?'✓':'✗'}</span>`;+ const chk3d = (s) => {+ if (!s.three_d) return miniChk(false);+ if (s.three_d_from_lib) return miniChk(true);+ // No 3D in this library → we swapped in the global chip STEP. Name where it came from.+ const where = s.step_source || 'another source';+ const tip = `there was no 3D chip in this library — so we swapped in the STEP we downloaded from ${where}${s.step_url ? ' (' + s.step_url + ')' : ''}`;+ return `<span data-tooltip="${escapeHtml(tip)}" style="color:var(--green,#34d17e);font-weight:700;font-size:13px;cursor:help">✓<span style="color:var(--accent)">*</span></span>`;+ };+ // Download links. ORIGINAL (the raw file/archive downloaded off the internet)+ // is styled as a solid accent pill ⭳; EXTRACTED files are outline pills ⬇.+ const dlLinks = (c, s) => (s.files || []).map(f => {+ const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file);+ const tip = f.original+ ? 'ORIGINAL file exactly as downloaded from the internet — inspect it yourself: ' + f.file+ : 'a specific file we extracted from the download: ' + f.file;+ const style = f.original+ ? 'color:#06251f;background:var(--accent);font-weight:700;border:1px solid var(--accent);'+ : 'color:var(--accent);border:1px solid rgba(0,184,177,.3);';+ return `<a href="${url}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml(tip)}" style="${style}font-size:10.5px;text-decoration:none;border-radius:4px;padding:1px 7px;margin-left:6px;white-space:nowrap">${f.original?'⭳':'⬇'} ${escapeHtml(f.name)}</a>`;+ }).join('');+ const childRows = (c) => (c.sources || []).map(s => `+ <tr style="background:rgba(0,184,176,.035)">+ <td colspan="11" style="padding:4px 0 4px 30px;border-top:1px solid rgba(255,255,255,.04)">+ <span style="color:var(--accent);font-size:11px">↳</span>+ <span style="color:var(--text2);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px">${s.label}</span>+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">sym</span> ${miniChk(s.sym, s.note)}+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">fp</span> ${miniChk(s.fp, s.note)}+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">3d chip</span> ${chk3d(s)}+ <span style="margin-left:22px;display:inline-flex;align-items:center">${dlLinks(c, s)}</span>+ </td>+ </tr>`).join('');+ const rows = (data.chips || []).map(c => {+ const reqCells = REQ.map(([k]) => cell(!!(c.required && c.required[k]))).join('');+ const nm = c.complete ? 'var(--green,#34d17e)' : '#ff5252';+ return `<tr>+ <td style="font-family:'JetBrains Mono',monospace;font-size:12px;white-space:nowrap;padding-right:14px;color:var(--text)">${c.mpn}</td>+ ${reqCells}+ <td style="text-align:center">${cell(!!(c.extra && c.extra.fusion_lbr)).replace('<td','<span').replace('</td>','</span>')}</td>+ ${consensusCell(c.consensus)}+ <td style="text-align:center;font-weight:700;color:${nm};white-space:nowrap">${c.complete ? '✓ COMPLETE' : '✗ INCOMPLETE'}</td>+ </tr>${childRows(c)}`;+ }).join('');+ const head = REQ.map(([,l]) => `<th style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:0 4px">${l}</th>`).join('');+ host.innerHTML = `+ <div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">+ <div style="font-size:18px;font-weight:700;color:var(--text)">Validation${data.project ? ' — ' + data.project : ''}</div>+ <div style="font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;background:${allGreen ? 'rgba(52,209,126,.18)' : 'rgba(255,82,82,.16)'};color:${allGreen ? 'var(--green,#34d17e)' : '#ff5252'};border:1px solid ${allGreen ? 'rgba(52,209,126,.5)' : 'rgba(255,82,82,.45)'}">+ ${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ' — ALL GREEN ✓' : ' — ' + (s.incomplete || 0) + ' INCOMPLETE'}+ </div>+ <div style="font-size:11px;color:var(--muted)">required: datasheet + STEP + symbol + footprint + 3 thumbnails. Auto-refreshes.</div>+ </div>+ <table style="border-collapse:collapse;width:100%">+ <thead><tr>+ <th style="text-align:left;font-size:11px;color:var(--muted)">MPN</th>${head}+ <th style="font-size:10px;color:var(--muted)">.lbr</th>+ <th style="font-size:10px;color:var(--muted)" data-tooltip="adom-concur — cross-source consensus: do the KiCad / EAGLE / Altium / ds2sf variants of this part agree?">CONSENSUS<br><span style="font-size:8px;color:var(--accent)">adom-concur</span></th>+ <th style="font-size:11px;color:var(--muted)">VERDICT</th>+ </tr></thead>+ <tbody>${rows || '<tr><td colspan="10" style="color:var(--muted);padding:14px">no chips in scope</td></tr>'}</tbody>+ </table>`;+}+document.getElementById('btn-validate').addEventListener('click', () => { if (sourcesOn) setSourcesView(false); setValidationView(!validationOn); });++// ---------------- Sourcing priority tab ----------------+// The exact, code-backed order chip-fetcher searches the internet for data.+// Rendered straight from /api/sources so it can NEVER drift from real behavior.+let sourcesOn = false;+const TIER_STYLE = {+ 'Manufacturer': { bg:'rgba(0,184,176,.16)', fg:'#00e6dc', bd:'rgba(0,184,176,.5)' },+ 'Adom tool': { bg:'rgba(140,107,247,.18)', fg:'#b9a4ff', bd:'rgba(140,107,247,.55)' },+ 'Aggregator skill':{ bg:'rgba(100,171,255,.15)', fg:'#8cc2ff', bd:'rgba(100,171,255,.5)' },+ 'Last resort': { bg:'rgba(255,82,82,.14)', fg:'#ff7676', bd:'rgba(255,82,82,.45)' },+};+function setSourcesView(on) {+ sourcesOn = on;+ if (on && validationOn) setValidationView(false);+ document.getElementById('grid-view').style.display = on ? 'none' : '';+ document.getElementById('sources-view').style.display = on ? 'block' : 'none';+ document.getElementById('btn-sources').style.background = on ? 'var(--accent)' : '';+ document.getElementById('btn-sources').style.color = on ? '#fff' : '';+ if (on) renderSources();+}+async function renderSources() {+ const host = document.getElementById('sources-view');+ let data;+ try { data = await (await fetch(API_BASE + '/api/sources?v=' + Date.now())).json(); }+ catch (e) { host.innerHTML = '<div class="empty-state">sources API error</div>'; return; }+ const badge = (tier) => {+ const s = TIER_STYLE[tier] || { bg:'rgba(255,255,255,.06)', fg:'var(--muted)', bd:'var(--border)' };+ return `<span style="font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:2px 7px;border-radius:5px;background:${s.bg};color:${s.fg};border:1px solid ${s.bd};white-space:nowrap">${tier}</span>`;+ };+ const concerns = (data.concerns || []).map(c => {+ const steps = (c.steps || []).map((st, i) => `+ <div style="display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)">+ <div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:var(--accent);min-width:22px;text-align:right">${i + 1}.</div>+ <div style="min-width:118px;display:flex;flex-direction:column;gap:5px">${badge(st.tier)}</div>+ <div style="flex:1;min-width:0">+ <div style="font-size:14px;font-weight:700;color:var(--text)">${st.name}</div>+ <div style="font-size:12.5px;color:var(--text2);line-height:1.45;margin-top:2px">${st.detail || ''}</div>+ ${st.cmd ? `<code style="display:inline-block;margin-top:5px;font-size:11.5px;color:var(--accent);background:rgba(0,184,176,.07);border:1px solid rgba(0,184,176,.2);border-radius:5px;padding:2px 7px">${st.cmd}</code>` : ''}+ ${st.skill ? `<span style="font-size:10.5px;color:var(--muted);margin-left:6px">↳ ${st.skill}</span>` : ''}+ </div>+ </div>`).join('');+ return `+ <div style="margin-bottom:26px;background:var(--card,rgba(255,255,255,.02));border:1px solid var(--border);border-radius:12px;padding:16px 18px">+ <div style="font-size:16px;font-weight:800;color:var(--text)">${c.concern}</div>+ <div style="font-size:12.5px;color:var(--text2);margin:4px 0 6px">${c.what || ''}</div>+ <div style="font-size:12px;color:var(--accent);background:rgba(0,184,176,.07);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px">▸ ${c.rule || ''}</div>+ ${steps}+ </div>`;+ }).join('');+ host.innerHTML = `+ <div style="max-width:920px">+ <div style="font-size:20px;font-weight:800;color:var(--text);margin-bottom:4px">Sourcing priority — how chip-fetcher searches the internet</div>+ <div style="font-size:12.5px;color:var(--muted);margin-bottom:18px">The real, code-backed order (served from <code>/api/sources</code>). Three concerns, each searched top-to-bottom. <b style="color:var(--text2)">Manufacturer-first for CAD; Adom data tools (keys baked in, always work) for metadata; manual-crawl skills are the fallback; LCSC is dead last.</b></div>+ ${concerns}+ </div>`;+}+document.getElementById('btn-sources').addEventListener('click', () => setSourcesView(!sourcesOn));++// ---------------- credential vault ----------------+const credsModal = document.getElementById('creds-modal');+const credsMsg = document.getElementById('cred-msg');+document.getElementById('btn-creds').addEventListener('click', () => {+ credsModal.classList.add('open');+ credsMsg.textContent = '';+ loadCreds();+});+document.getElementById('cred-close').addEventListener('click', () => credsModal.classList.remove('open'));+credsModal.addEventListener('click', (e) => { if (e.target === credsModal) credsModal.classList.remove('open'); });++async function loadCreds() {+ const list = document.getElementById('creds-list');+ list.innerHTML = '<div class="info">loading…</div>';+ try {+ const r = await fetch(API_BASE + '/api/credentials');+ if (!r.ok) {+ const err = await r.text();+ list.innerHTML = '<div class="info">credential vault unavailable: ' + escapeHtml(err) + '</div>';+ return;+ }+ const d = await r.json();+ const creds = d.credentials || [];+ if (creds.length === 0) {+ list.innerHTML = '<div class="info">no credentials saved yet</div>';+ return;+ }+ list.innerHTML = creds.map(c =>+ `<div class="row"><div><code>${escapeHtml(c.host)}</code><div class="info">${escapeHtml(c.username)} · <span class="pw-mask" data-pw="${escapeHtml(c.password || '')}" data-tooltip="Click to reveal / hide this saved password." style="cursor:pointer; opacity:.7; user-select:none;">••••••••</span></div></div>` ++ `<button data-host="${escapeHtml(c.host)}">delete</button></div>`+ ).join('');+ list.querySelectorAll('button').forEach(btn => {+ btn.onclick = () => deleteCred(btn.dataset.host);+ });+ list.querySelectorAll('.pw-mask').forEach(span => {+ span.onclick = () => {+ const masked = span.textContent === '••••••••';+ span.textContent = masked ? span.dataset.pw : '••••••••';+ span.setAttribute('data-tooltip', masked ? 'Click to hide this password.' : 'Click to reveal this password.');+ span.style.userSelect = masked ? 'text' : 'none';+ };+ });+ } catch (e) {+ list.innerHTML = '<div class="info">load error: ' + escapeHtml(e.message) + '</div>';+ }+}++async function deleteCred(host) {+ if (!confirm('Delete credentials for ' + host + '?')) return;+ const r = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(host), {method: 'DELETE'});+ showMsg(r.ok ? 'deleted' : 'delete failed', r.ok);+ loadCreds();+}++document.getElementById('cred-save').addEventListener('click', async () => {+ const host = document.getElementById('cred-host').value.trim();+ const username = document.getElementById('cred-user').value.trim();+ const password = document.getElementById('cred-pass').value;+ if (!host || !username || !password) {+ showMsg('host, username, password all required', false);+ return;+ }+ showMsg('saving…', true);+ try {+ const r = await fetch(API_BASE + '/api/credentials', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({host, username, password})+ });+ if (r.ok) {+ showMsg('credentials saved', true);+ document.getElementById('cred-pass').value = '';+ loadCreds();+ } else {+ const err = await r.text();+ showMsg('save failed: ' + err, false);+ }+ } catch (e) {+ showMsg('save error: ' + e.message, false);+ }+});++function showMsg(text, ok) {+ credsMsg.className = 'msg ' + (ok ? 'ok' : 'err');+ credsMsg.textContent = text;+}++load().catch(e => {+ document.getElementById('cards').innerHTML = '<div class="empty-state">load error: ' + e.message + '</div>';+});+// ---- Tooltip system per human-ui-patterns skill ----+// Body-appended position:fixed div at z-index 99999, 600ms delay,+// auto-flip when near viewport edge. Every interactive element uses+// data-tooltip="..." for descriptive, newbie-friendly help text.+(function initTooltips() {+ const tip = document.createElement('div');+ tip.style.cssText = 'position:fixed;z-index:99999;background:#1a1f2e;color:#e6edf3;border:1px solid #3d4450;border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.4;max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;transition:opacity .15s;white-space:pre-wrap;font-family:Satoshi,-apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,0.5)';+ document.body.appendChild(tip);+ let timer = null;+ // Touch-primary devices (phones) emulate mouseover on tap but never fire+ // mouseout, so hover-tooltips stick and cover content. Disable them there;+ // touch users tap elements directly. Also dismiss on any tap/scroll.+ const TOUCH = window.matchMedia && window.matchMedia('(hover: none)').matches;+ const hide = () => { clearTimeout(timer); tip.style.opacity = '0'; };+ document.addEventListener('touchstart', hide, { passive: true });+ window.addEventListener('scroll', hide, { passive: true, capture: true });+ document.addEventListener('mouseover', (ev) => {+ if (TOUCH) return;+ const el = ev.target.closest('[data-tooltip]');+ if (!el) return;+ clearTimeout(timer);+ timer = setTimeout(() => {+ tip.textContent = el.dataset.tooltip;+ tip.style.opacity = '1';+ const r = el.getBoundingClientRect();+ let left = r.left;+ tip.style.left = '0'; tip.style.top = '0'; // reset for measurement+ const tw = tip.offsetWidth, th = tip.offsetHeight;+ // Prefer ABOVE the element — these chips/badges/pulldowns sit at the top+ // of their card, so a tooltip below covers the thumbnail. Flip below only+ // when there genuinely isn't room above (element near the viewport top).+ let top = r.top - th - 6;+ if (top < 8) top = r.bottom + 6;+ if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;+ if (left < 8) left = 8;+ tip.style.left = left + 'px';+ tip.style.top = top + 'px';+ }, 600);+ });+ document.addEventListener('mouseout', (ev) => {+ const el = ev.target.closest('[data-tooltip]');+ if (!el) return;+ clearTimeout(timer);+ tip.style.opacity = '0';+ });+})();+</script>+</body>+</html>
src/validate.rs+10−8@@ -7,8 +7,8 @@ // serde tagged-union deserialization and `match` on the action / kind. // // Read both contracts before touching this file:-// ~/.claude/skills/ds2sf/SKILL.md-// ~/.claude/skills/concur/SKILL.md+// ~/.claude/skills/adom-ds2sf/SKILL.md+// ~/.claude/skills/adom-concur/SKILL.md use anyhow::{anyhow, Context, Result}; use serde::Deserialize;@@ -400,7 +400,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { // ---------- subprocess helpers ---------- fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> Result<Ds2sfResult> {- let mut cmd = Command::new("ds2sf");+ let mut cmd = Command::new("adom-ds2sf"); cmd.arg("extract").arg(chip_dir).arg("--force").arg("--json-only"); if let Some(p) = pdf_override { cmd.arg("--pdf").arg(p);@@ -410,7 +410,7 @@ fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> Result<Ds2sfResult } fn run_ds2sf_with_model(chip_dir: &Path, model: &str) -> Result<Ds2sfResult> {- let mut cmd = Command::new("ds2sf");+ let mut cmd = Command::new("adom-ds2sf"); cmd.arg("extract").arg(chip_dir) .arg("--force").arg("--json-only") .arg("--model").arg(model);@@ -430,7 +430,7 @@ fn read_ds2sf_result(chip_dir: &Path) -> Result<Ds2sfResult> { } fn run_concur(chip_dir: &Path) -> Result<ConcurResult> {- let mut cmd = Command::new("concur");+ let mut cmd = Command::new("adom-concur"); cmd.arg("check").arg(chip_dir).arg("--json-only"); let _ = cmd.status().with_context(|| "running concur check")?; read_concur_result(chip_dir)@@ -566,13 +566,15 @@ fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Resul return Err(anyhow!("could not normalize source slug from {source:?}")); }; - println!("OK: invoking `chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");- let status = std::process::Command::new("chip-fetcher")+ println!("OK: invoking `adom-chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");+ // Self-invoke by absolute path — name-independent across renames.+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());+ let status = std::process::Command::new(exe) .args(["refetch", mpn, "--format", format, "--source", source_slug]) .status()- .map_err(|e| anyhow!("could not exec chip-fetcher refetch: {e}"))?;+ .map_err(|e| anyhow!("could not exec adom-chip-fetcher refetch: {e}"))?; if status.success() { // refetch_cmd already appended an OK chain entry on success.
tool-map.htmladded+196@@ -0,0 +1,196 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>chip-fetcher — Tool Map</title>+<style>+ :root{+ --bg:#0a0e14; --panel:#0d1117; --ink:#e6edf3; --muted:#8b949e;+ --local:#34d17e; --local-bg:rgba(52,209,126,.08); --local-bd:rgba(52,209,126,.35);+ --remote:#ff9a4a; --remote-bg:rgba(255,154,74,.09); --remote-bd:rgba(255,154,74,.40);+ --desktop:#8c6bf7;--desktop-bg:rgba(140,107,247,.10);--desktop-bd:rgba(140,107,247,.40);+ --lib:#64abff; --lib-bg:rgba(100,171,255,.09); --lib-bd:rgba(100,171,255,.38);+ --hub:#00e6dc; --hub-bg:rgba(0,230,220,.10); --hub-bd:rgba(0,230,220,.45);+ --new:#ffd23f;+ }+ *{box-sizing:border-box}+ body{margin:0;background:+ radial-gradient(1200px 700px at 85% -10%,rgba(0,184,177,.10),transparent 60%),+ radial-gradient(900px 600px at -5% 110%,rgba(140,107,247,.10),transparent 55%),+ linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318);+ color:var(--ink);font-family:'Familjen Grotesk',system-ui,-apple-system,Segoe UI,sans-serif;+ -webkit-font-smoothing:antialiased;padding:24px 16px 60px}+ h1{font-size:26px;margin:0 0 2px;letter-spacing:-.5px}+ h1 span{background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}+ .sub{color:var(--muted);font-size:14px;margin:0 0 18px}+ .wrap{max-width:1100px;margin:0 auto}++ /* legend */+ .legend{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 22px}+ .lg{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--muted);+ border:1px solid #222a33;border-radius:999px;padding:5px 11px;background:#0e141c}+ .dot{width:11px;height:11px;border-radius:3px;flex:none}++ /* tiers */+ .tier{margin:0 0 14px}+ .tier-h{display:flex;align-items:baseline;gap:8px;margin:0 0 8px}+ .tier-h b{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink)}+ .tier-h em{font-style:normal;color:var(--muted);font-size:12px}+ .row{display:flex;flex-wrap:wrap;gap:10px}++ .node{border-radius:12px;padding:10px 12px;min-width:150px;flex:1 1 150px;max-width:230px;+ border:1px solid #222a33;background:#0e141c;position:relative}+ .node .nm{font-weight:700;font-size:14px;display:flex;align-items:center;gap:6px}+ .node .dsc{color:var(--muted);font-size:11.5px;margin-top:3px;line-height:1.35}+ .node .calls{margin-top:7px;font-size:11px;color:#aeb8c4}+ .node .calls b{color:var(--remote)}+ .tag{font-size:9.5px;font-weight:700;letter-spacing:.04em;padding:2px 6px;border-radius:5px;text-transform:uppercase}+ .t-local{color:var(--local);background:var(--local-bg);border:1px solid var(--local-bd)}+ .t-remote{color:var(--remote);background:var(--remote-bg);border:1px solid var(--remote-bd)}+ .t-desktop{color:var(--desktop);background:var(--desktop-bg);border:1px solid var(--desktop-bd)}+ .t-lib{color:var(--lib);background:var(--lib-bg);border:1px solid var(--lib-bd)}+ .new-pill{font-size:9px;font-weight:800;color:#0a0e14;background:var(--new);border-radius:5px;padding:1px 5px;letter-spacing:.03em}++ .local{border-color:var(--local-bd)}+ .remote{border-color:var(--remote-bd);background:var(--remote-bg)}+ .desktop{border-color:var(--desktop-bd);background:var(--desktop-bg)}+ .lib{border-color:var(--lib-bd);background:var(--lib-bg)}++ .hub{border:1.5px solid var(--hub-bd);background:var(--hub-bg);max-width:none;flex-basis:100%}+ .hub .nm{font-size:18px;color:var(--hub)}++ .arrow{text-align:center;color:#3a4452;font-size:18px;margin:2px 0;line-height:1}++ /* explainer cards */+ .ex{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:26px 0 6px}+ @media(max-width:680px){.ex{grid-template-columns:1fr}}+ .card{border:1px solid #222a33;border-radius:14px;padding:14px 16px;background:#0e141c}+ .card h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px}+ .card p{margin:0 0 8px;font-size:13px;color:#c4cdd8;line-height:1.5}+ .card .k{font-size:12px;color:var(--muted)}+ code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11.5px;background:#11181f;border:1px solid #1c242d;padding:1px 5px;border-radius:5px;color:#cfe}++ .count{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;+ padding:0 6px;border-radius:7px;background:var(--hub-bg);border:1px solid var(--hub-bd);color:var(--hub);font-weight:800;font-size:13px}+ .note{font-size:12px;color:var(--muted);margin-top:14px;line-height:1.5}+</style>+</head>+<body>+<div class="wrap">+ <h1><span>chip-fetcher</span> — tool map</h1>+ <p class="sub">What runs <em>in your container</em> vs. what we <em>call over the network</em>. Generated 2026-06-24.</p>++ <div class="legend">+ <span class="lg"><span class="dot" style="background:var(--hub)"></span>orchestrator</span>+ <span class="lg"><span class="dot" style="background:var(--local)"></span>local CLI — runs in your container</span>+ <span class="lg"><span class="dot" style="background:var(--lib)"></span>local library (Rust crate, not a separate app)</span>+ <span class="lg"><span class="dot" style="background:var(--remote)"></span>remote service container — we call it over HTTPS</span>+ <span class="lg"><span class="dot" style="background:var(--desktop)"></span>your laptop — reached via adom-desktop bridge</span>+ </div>++ <!-- HUB -->+ <div class="tier">+ <div class="row">+ <div class="node hub">+ <div class="nm">⬢ adom-chip-fetcher</div>+ <div class="dsc">The orchestrator. Pulls every CAD format off the internet, converts + renders each, and shows them side-by-side so you can pick what to export.</div>+ </div>+ </div>+ </div>+ <div class="arrow">▼ shells out to (all local CLIs) ▼</div>++ <!-- CONVERSION + RENDER -->+ <div class="tier">+ <div class="tier-h"><b>Convert & render</b><em>turn each downloaded format into geometry + thumbnails</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-sfconvert <span class="new-pill">NEW</span></div><div class="dsc">EAGLE/Fusion .lbr → KiCad (pure Rust). Altium → KiCad.</div><div class="calls">uses lib <b style="color:var(--lib)">altium-pcblib</b> · symbols → <b>service-kicad</b></div></div>+ <div class="node local"><div class="nm">adom-symbol</div><div class="dsc">Renders the schematic symbol (with 3D chip embossed).</div><div class="calls">→ <b>service-kicad</b></div></div>+ <div class="node local"><div class="nm">adom-footprint</div><div class="dsc">Renders the PCB footprint SVG.</div><div class="calls">→ <b>service-kicad</b></div></div>+ <div class="node local"><div class="nm">adom-ds2sf</div><div class="dsc">Extracts sym+footprint from the datasheet PDF.</div><div class="calls">local (uses <code>claude</code> + pdf tools)</div></div>+ <div class="node local"><div class="nm">adom-concur</div><div class="dsc">Cross-checks all sources for agreement.</div><div class="calls">uses lib <b style="color:var(--lib)">altium-pcblib</b></div></div>+ <div class="node local"><div class="nm">adom-step</div><div class="dsc">Handles the 3D STEP model.</div><div class="calls">→ <b>step2glb</b></div></div>+ <div class="node local"><div class="nm">adom-lbr</div><div class="dsc">Side-by-side sym/fp/3D embeddable.</div><div class="calls">→ <b>wiki</b></div></div>+ <div class="node local"><div class="nm">adom-chip-thumbnailer</div><div class="dsc">Bakes the card thumbnails (sym/fp/3D icons).</div><div class="calls">local (OCCT + xvfb)</div></div>+ <div class="node local"><div class="nm">adom-chipsmith</div><div class="dsc">Validates the 3D chip against its footprint.</div><div class="calls">→ <b>service-kicad</b>, <b>step2glb</b></div></div>+ </div>+ </div>++ <!-- LIBS -->+ <div class="tier">+ <div class="tier-h"><b>Local libraries</b><em>Rust crates compiled in — not separate apps</em></div>+ <div class="row">+ <div class="node lib"><div class="nm">altium-pcblib <span class="tag t-lib">lib</span></div><div class="dsc"><b>The Altium reader you built.</b> Parses .PcbLib/.IntLib binary → KiCad pad JSON + SVG. 100% local, no network.</div></div>+ <div class="node lib"><div class="nm">eagle parser <span class="tag t-lib">lib</span></div><div class="dsc">Inside adom-sfconvert. Parses EAGLE/Fusion .lbr XML → KiCad. 100% local.</div></div>+ </div>+ </div>++ <!-- SOURCING -->+ <div class="tier">+ <div class="tier-h"><b>Sourcing</b><em>find the part & its data — each has a baked-in API key in its own service</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-parts-search</div><div class="dsc">The umbrella search.</div><div class="calls">→ <b>mouser</b>, <b>digikey</b>, <b>jlcpcb</b></div></div>+ <div class="node local"><div class="nm">adom-mouser</div><div class="calls">→ <b>mouser service</b></div></div>+ <div class="node local"><div class="nm">adom-digikey</div><div class="calls">→ <b>digikey service</b></div></div>+ <div class="node local"><div class="nm">adom-jlcpcb</div><div class="calls">→ <b>jlcpcb service</b></div></div>+ </div>+ </div>++ <!-- BRIDGE -->+ <div class="tier">+ <div class="tier-h"><b>Bridge & infra</b><em>reach your laptop / the platform</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-desktop</div><div class="dsc">Bridge to your real laptop.</div><div class="calls">→ <b style="color:var(--desktop)">your laptop</b></div></div>+ <div class="node local"><div class="nm">adom-cli</div><div class="dsc">Adom platform (containers, wiki, carbon).</div></div>+ <div class="node local"><div class="nm">adom-vscode</div><div class="dsc">Reveal files in the editor.</div></div>+ </div>+ </div>++ <div class="arrow">▼ the heavy lifting lives OFF your container ▼</div>++ <!-- REMOTE SERVICES -->+ <div class="tier">+ <div class="tier-h"><b>Remote service containers</b><em>shared, always-on, big installs you never put on your own container</em></div>+ <div class="row">+ <div class="node remote"><div class="nm">🌐 service-kicad <span class="tag t-remote">remote</span></div><div class="dsc"><b>KiCad 10</b> (~2 GB) headless. DRC/ERC, SVG/Gerber/STEP/GLB export, <b>and the Altium symbol importer</b>.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">kicad-rk5ue5pcfemi.adom.cloud</div></div>+ <div class="node remote"><div class="nm">🌐 step2glb <span class="tag t-remote">remote</span></div><div class="dsc">STEP → GLB 3D conversion (OCCT).</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">step2glb-…adom.cloud</div></div>+ <div class="node remote"><div class="nm">🌐 mouser / digikey / jlcpcb <span class="tag t-remote">remote ×3</span></div><div class="dsc">One service each, with the vendor API key baked in — so you never need a key.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">mouser-… · digikey-… · jlcpcb-…</div></div>+ <div class="node remote"><div class="nm">🌐 wiki <span class="tag t-remote">remote</span></div><div class="dsc">Publish & install pages/skills.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">wiki.adom.inc</div></div>+ </div>+ </div>++ <!-- DESKTOP -->+ <div class="tier">+ <div class="tier-h"><b>Your laptop</b><em>via the adom-desktop bridge — not a cloud service</em></div>+ <div class="row">+ <div class="node desktop"><div class="nm">🖥 real Chrome <span class="tag t-desktop">laptop</span></div><div class="dsc">Native-browser / pup automation for vendor downloads (your real logins).</div></div>+ <div class="node desktop"><div class="nm">🖥 KiCad / Fusion <span class="tag t-desktop">laptop</span></div><div class="dsc">Open the finished library in your actual EDA app.</div></div>+ </div>+ </div>++ <!-- EXPLAINERS -->+ <div class="ex">+ <div class="card">+ <h3>🟢 What's a “local CLI”?</h3>+ <p>A small command that's <b>installed in your cloud container</b> (in <code>~/.local/bin</code>) and runs right there. Fast, no network, nothing big to install. Example: <code>adom-sfconvert</code>, <code>adom-symbol</code>.</p>+ <p class="k">These are tiny Rust binaries (a few MB). They do the orchestration, parsing, and file-shuffling.</p>+ </div>+ <div class="card">+ <h3>🟠 What's a “remote service container”?</h3>+ <p>A <b>shared, always-on container we call over HTTPS</b>. It holds the heavy software (KiCad is ~2 GB) so it lives in <b>one</b> place instead of on every user's container.</p>+ <p class="k">When a local CLI needs KiCad, it POSTs a file to <code>service-kicad</code> and gets the result back. <b>Nothing ever installs KiCad on your container.</b></p>+ </div>+ </div>++ <div class="card" style="margin-top:14px">+ <h3><span class="count">15</span> apps chip-fetcher depends on</h3>+ <p>adom-sfconvert <span class="new-pill">NEW</span> · adom-symbol · adom-footprint · adom-ds2sf · adom-concur · adom-step · adom-lbr · adom-chip-thumbnailer · adom-chipsmith · adom-parts-search · adom-mouser · adom-digikey · adom-jlcpcb · adom-desktop · adom-cli (+ adom-vscode for editor reveal).</p>+ <p class="k">All 15 are <b style="color:var(--local)">local CLIs</b>. Between them they reach only <b style="color:var(--remote)">6 remote services</b> (service-kicad, step2glb, mouser, digikey, jlcpcb, wiki) and your laptop. The 2 GB KiCad never touches your container.</p>+ </div>++ <div class="note">+ <b>On Altium specifically:</b> only <b style="color:var(--lib)">altium-pcblib</b> <i>reads</i> Altium libraries (footprints, native/local). <code>adom-altium</code> is an empty stub. The other Altium pages on the wiki — <code>adom-altium-library-importer</code>, <code>altium-part-importer</code>, <code>adom-desktop-altium-bridge</code> (all barrett-land) — go the <b>opposite</b> direction: they write parts <i>into</i> Altium Designer on the desktop. For reading Altium <b>symbols</b> (.SchLib) there's still no native parser, so that one path uses <code>service-kicad</code>.+ </div>+</div>+</body>+</html>