← Commit history
.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--![Switch the source for a symbol or footprint](docs/carat.png)--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--![Tabbed detail panel: Actions, Details, Files](docs/details.png)--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. -![Full-screen zoom focus view](docs/zoom.png)+## 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 &mdash; STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet &mdash; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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">▼ &nbsp; shells out to (all local CLIs) &nbsp; ▼</div>++  <!-- CONVERSION + RENDER -->+  <div class="tier">+    <div class="tier-h"><b>Convert &amp; 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 &amp; 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 &amp; 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">▼ &nbsp; the heavy lifting lives OFF your container &nbsp; ▼</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 &amp; 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> &nbsp;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 &nbsp;(+ 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>