app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
← Commit history
Gorgeous README + billboard hero + inline screenshots; Wiki v2 page.json
25 files changed
+3327−62
.gitignore+3Cargo.lock+856Cargo.toml+24README.md+79−26SKILL.md+183−4VERSION+1build.sh+27docs/align-gallery.html+53docs/icon.svg+14docs/thumbnail.pngdocs/thumbnail.svg+28package.json+2−6page.json+41−26publish.sh+147screenshots/cli.pngscreenshots/compare-grid.pngscreenshots/hero.pngscreenshots/policy-switch.pngsrc/app/mod.rs+207src/app/tab.rs+134src/app/ui.html+455src/cli.rs+231src/install.rs+95src/main.rs+179src/vendors.rs+568.gitignoreadded+3@@ -0,0 +1,3 @@+/target+/.env+*.log
Cargo.lockadded+856@@ -0,0 +1,856 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-parts-search"+version = "0.1.7"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[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 0.61.2",+]++[[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 0.61.2",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "cc"+version = "1.2.60"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[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_complete"+version = "4.6.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"+dependencies = [+ "clap",+]++[[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 = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[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 = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[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 = "libc"+version = "0.2.185"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[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 = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[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 = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.38"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[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 = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[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 = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[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 = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.7",+]++[[package]]+name = "webpki-roots"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"+dependencies = [+ "rustls-pki-types",+]++[[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.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[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 = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+24@@ -0,0 +1,24 @@+[package]+name = "adom-parts-search"+version = "0.1.7"+edition = "2021"+description = "Parts Search Electronics component search — CLI, backend HTTP service, and Hydrogen webview app"+license = "MIT"++[[bin]]+name = "adom-parts-search"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+base64 = "0.22"++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
README.md+79−26@@ -1,46 +1,99 @@-# adom-parts-search+# Adom Parts Search -Parallel parts search across **Mouser + DigiKey + JLCPCB**, rendered side-by-side with product photos. Uses the existing vendor CLIs (`adom-mouser`, `adom-digikey`, `adom-jlcpcb`) as primitives and applies the Adom Mouser-preferred routing policy.+**One search box. Mouser, DigiKey and JLCPCB at once. The best buy, chosen for you.** -+ -## Why Mouser is preferred+Sourcing a part used to mean three browser tabs, three logins, three different ways of saying+"in stock," and a mental spreadsheet to figure out who actually gets it to your bench fastest.+Adom Parts Search collapses that into a single query. Type a part — or just ask Claude — and it+fires **three parallel searches**, normalizes the answers into one comparable shape, and tells you+who to order from and why. -- **40-minute drone delivery** from Mouser's Mansfield, TX fulfillment center to our Fort Worth facility (~15 miles)-- **Near-zero shipping cost** (drone route, Mouser-operated)-- DigiKey is only picked when Mouser is out of stock, lead time is >14 days, or DigiKey is >30% cheaper on volume+It's a single Rust binary with two faces: a **CLI** for scripting and BOMs, and a first-class+**Hydrogen webview** for browsing side-by-side with real product photos. -## Use+```bash+adompkg install adom-parts-search+adom-parts-search show "STM32F103RBT6"+```++## Everything, from one interface++No more tab-juggling. One keyword goes to Mouser, DigiKey and JLCPCB simultaneously; the results+land in three aligned columns so you can compare manufacturer, package, price, stock and lead time+at a glance. A recommendation banner sits on top and names the single best place to buy.++++The recommendation is **Mouser-preferred for a concrete reason**: a 40-minute drone hop from+Mouser's Mansfield, TX warehouse to our Fort Worth facility means near-zero shipping and same-hour+parts. When Mouser has it in stock without a long lead, Mouser wins.++## A recommendation that actually reasons++The winner isn't always Mouser — and the banner tells you when it flips. If Mouser is out of+stock, sitting on a 280-day lead, or the part is NRND, the policy steps aside and points you at+whoever can actually ship today.++++- **Mouser wins** when it's in stock with no long-lead penalty — drone delivery + \$0 shipping.+- **DigiKey wins** when Mouser is out, long-lead, or NRND and DigiKey can ship now.+- **JLCPCB** rides along as an assembly-library column — great for "is this a Basic part?" but it+ doesn't compete on the buy-now recommendation (different workflow).++## Built for scripts and BOMs too++The webview is for browsing; the CLI is for everything else. `search` returns clean JSON for+piping into `jq`, BOM tooling, or another Claude step. `show` drives the webview *and* prints the+full per-vendor breakdown to your terminal.++ ```bash-# CLI (JSON on stdout, unified across all 3 vendors)-adom-parts-search search "iCE40HX4K-TQ144" --limit 5+# Browse — opens/refreshes the Parts Search Hydrogen tab + prints results+adom-parts-search show "ESP32-S3-WROOM-1U-N4R2" --limit 8 --in-stock-only++# Script — JSON only, no webview+adom-parts-search search "LM555" | jq '.recommendation' -# Hydrogen webview — 3-column grid with product photos, buy/datasheet links, zoom+# Keep the app tab alive without a query adom-parts-search app ``` -## Architecture+## Install++```bash+adompkg install adom-parts-search+```++That's it — `adompkg` pulls the binary and registers the skill so Claude can drive it. The `show`+subcommand auto-starts the local app server on first call and opens the **Parts Search** Hydrogen+tab; later calls reuse the same server and re-render in place. -Client-only, no service container of its own. Coordinates three existing Tier A vendor backends:+## How it works++Adom Parts Search is **client-only** — it owns no API keys and runs no service of its own. It+coordinates the three existing vendor backends, each of which handles its own auth, caching, quota+and watchdog: ```- any user container- │- ▼- adom-parts-search CLI / app- │ (3 parallel HTTP POSTs)- ├──▶ mouser-xr8t4f5d01bt.adom.cloud (adom-mouser)- ├──▶ digikey-gjuu8l5t69uh.adom.cloud (adom-digikey)- └──▶ jlcpcb-wela51osctvp.adom.cloud (adom-jlcpcb)+ adom-parts-search show "…"+ │ (3 parallel HTTPS queries)+ ├──▶ service-mouser (adom-mouser serve)+ ├──▶ service-digikey (adom-digikey serve)+ └──▶ service-jlcpcb (adom-jlcpcb serve) ``` -Each vendor backend self-heals via its own cron watchdog (every 2 min). This app just aggregates + renders.+It aggregates the three responses, applies the Mouser-preferred recommendation, and renders. If a+backend is rate-limited or down, its column shows a friendly error and the recommendation reasons+around it instead of failing the whole search. -## Install+## See also -Tier A — `gallia/install.mjs` pulls it from the wiki on every gallia container. Or run the paste-install one-liner on the wiki page.+- [`adom-mouser`](/app/adom-mouser) · [`adom-digikey`](/app/adom-digikey) · [`adom-jlcpcb`](/app/adom-jlcpcb) — the single-vendor tools this app federates. -## Source+## License -[adom-inc/adom-parts-search](https://github.com/adom-inc/adom-parts-search) (private).+MIT.
SKILL.md+183−4@@ -1,7 +1,186 @@ --- name: adom-parts-search-description: >- Unified parts search across Mouser + DigiKey + JLCPCB in parallel. Side-by-side comparison with product photos. Mouser-preferred routing (40-min drone to Fort Worth, near-zero shipping).+description: Unified parts search across Mouser + DigiKey + JLCPCB with Mouser-preferred routing (40-min drone delivery from Mansfield, TX to our Fort Worth facility; near-zero shipping). CLI and Hydrogen webview app. Use when the user asks to source a part, price a component, compare vendors, check stock across distributors, or assemble a BOM. Trigger words — find part, search parts, source component, parts search, component search, where can I get, compare distributors, Mouser or DigiKey, compare prices, BOM sourcing, stock check, parts availability, open parts search, parts search app, parts search UI. ----# Adom Parts Search-Unified parts search across Mouser + DigiKey + JLCPCB in parallel. Side-by-side comparison with product photos. Mouser-preferred routing (40-min drone to Fort Worth, near-zero shipping).++# adom-parts-search++Searches Mouser + DigiKey + JLCPCB in parallel. Rust CLI + Hydrogen webview. Recommends **Mouser by default** because our Fort Worth facility has a 40-minute drone delivery arrangement with Mouser's Mansfield, TX fulfillment center + near-zero shipping cost.++## CLI++**For user-facing searches, always use `show`.** It drives the webview tab AND prints the full vendor-by-vendor result to stdout, so the user sees product photos + 3-column comparison and the AI sees the same data textually. Auto-starts the app server + opens the tab on first run.++```bash+# ✅ DEFAULT — every search a human asks for+adom-parts-search show "ESP32-S3-WROOM-1U-N4R2"+adom-parts-search show "iCE40HX4K-TQ144" --limit 8 --in-stock-only++# Scripting / piping / batch only (no webview, pure JSON)+adom-parts-search search "STM32F103RBT6" | jq '.recommendation'++# Keep the app tab running without triggering a search+adom-parts-search app+```++### Decision rule++- User asked a conversational question about a part → **`show`**. Always.+- You're piping into `jq`, `awk`, or a batch script → `search`.+- Silence: never just dump JSON to chat. If you would have run `search`, run `show` instead — the user wants photos and side-by-side comparison, not a wall of JSON.++### Extract a clean MPN / part-family BEFORE calling `show`++Vendor APIs (Mouser, DigiKey, JLCPCB) do substring match on MPN and description, NOT full-text intent search. If the user says "find me a 3.3V LDO with 500 mA", do NOT pipe `"find me a 3.3V LDO with 500 mA"` into `show` — that's natural language, and every vendor will return zero hits. Extract the likely part family or MPN first, then call `show`.++**Extraction examples:**++| User said | Pass to `show` | Why |+|---|---|---|+| "find me a 555 timer" | `LM555` | Canonical 555-family part. Also try `NE555` if no hits. |+| "search for esp32 dev boards" | `ESP32` | Broad family match; narrow later with `ESP32-S3` if the user wants a specific variant. |+| "need a 3.3V LDO 500mA" | `LM1117-3.3` or `AMS1117-3.3` | Well-known 3.3V LDOs; pick one, try the other if empty. |+| "stm32 bluepill chip" | `STM32F103C8T6` | The canonical Blue Pill MCU. |+| "what op-amp for unity gain audio" | `TL072` or `NE5532` | Common audio op-amps. Don't pass "op-amp audio" as a keyword. |+| "lm333 timer" | Ask first — LM333 isn't a 555-family timer. Probably `LM555` (typo). | Don't burn a search on a bogus part number. |++**Rules of thumb:**++- If the user typed a clearly valid MPN (has letters + digits + optional dash, 4+ chars, looks like `STM32F103RBT6` / `LM555` / `ESP32-S3-WROOM-1U-N4R2`), pass it through verbatim.+- If the user typed a natural-language description, mentally map it to the 1-2 best-known part families and pass the first one to `show`. If that returns 0, try the second.+- If the user named a part that doesn't match a family you recognize (`LM333` for "timer"), **stop and ask one clarifying question** before searching — don't guess your way into a bogus API call.+- If the user names an exact variant (e.g. "ESP32-S3-WROOM-1U-N4R2"), use that; don't broaden it unless a search returns 0.++### Handling empty results++If `show` returns 0 hits across all three vendors AND no vendor errored, the CLI will already print a helpful "no matches" line and the webview will render an empty-state card with suggestions. You still need to decide what to do next:++1. Try one alternative family name (e.g. `LM555` → `NE555` → `LM556`).+2. If the alternative also returns 0, surface that to the user with a brief explanation: "Neither LM555 nor NE555 matched — double-check the MPN or describe the part differently."+3. Do NOT silently run five searches in a row with minor variations — ask the user for a better name after the second attempt.++### Common part families + corrections++| If the user said… | Try first | Also valid |+|---|---|---|+| "555 timer" / "monostable timer" | `LM555` | `NE555`, `LM556`, `TLC555` |+| "3.3V LDO" | `LM1117-3.3` | `AMS1117-3.3`, `AP2112K-3.3` |+| "5V LDO" | `LM7805` | `LM1117-5.0`, `AMS1117-5.0` |+| "op-amp" (audio) | `TL072` | `NE5532`, `OPA2134` |+| "op-amp" (general) | `LM358` | `MCP6002`, `TL074` |+| "FPGA" (Lattice ice40 family) | `iCE40HX4K-TQ144` | `iCE40UP5K`, `iCE40LP1K` |+| "ESP32" (generic) | `ESP32-WROOM-32` | `ESP32-S3`, `ESP32-C3` |+| "STM32 Blue Pill" | `STM32F103C8T6` | `STM32F103RBT6` |+| "Arduino Nano MCU" | `ATmega328P` | `ATmega168` |+| "USB-C connector" | `USB4085` | `USB4110`, `GCT USB4105` |++If you see the user invoke a part number that doesn't look right (like `LM333` for "timer"), consult this table FIRST, ask a one-line clarifying question, and only call `show` after the user confirms the correction.++Stdout from `show` looks like:++```+adom-parts-search keyword: "iCE40HX4K-TQ144"++MOUSER total= 1 hits= 1 412ms [ok]+ iCE40HX4K-TQ144 Lattice Semicon… $10.20 stock 42+DIGIKEY total= 1 hits= 1 676ms [ok]+ ICE40HX4K-TQ144 Lattice Semicon… $11.22 stock 413+JLCPCB total= 0 hits= 0 74ms [ok]++RECOMMENDATION → Mouser iCE40HX4K-TQ144 $10.20 stock 42+ Mouser stocked — 40-min drone delivery from Mansfield.+```++followed by the full unified JSON (same schema as `search` below) so downstream tooling has structured access.++The `search` output is a unified JSON:++```json+{+ "query": "iCE40HX4K-TQ144",+ "mouser": { "ok": true, "total": 1, "hits": [...], "elapsed_ms": 412 },+ "digikey": { "ok": true, "total": 1, "hits": [...], "elapsed_ms": 630 },+ "jlcpcb": { "ok": true, "total": 0, "hits": [], "elapsed_ms": 180 },+ "recommendation": {+ "winner": "Mouser",+ "mpn": "iCE40HX4K-TQ144",+ "unit_price": "$10.20",+ "stock": 42,+ "reason": "Mouser stocked — 40-min drone delivery from Mansfield, near-zero shipping."+ }+}+```++Each `hit` includes `image_url` for the webview — use the `app` subcommand when the user needs to eyeball product photos (Claude Code terminal can't show images).++## Webview app++`adom-parts-search app` opens a Hydrogen tab titled **"Parts Search"** with a 3-column grid:++- **Mouser column** (blue top border) — preferred, badged "40-min drone · $0 ship"+- **DigiKey column** (red top border) — alternative+- **JLCPCB column** (green top border) — parts library only (SMT assembly)++Each card shows the product photo, MPN, vendor PN, manufacturer, price (@1 and @100), stock, lead time, flags (RoHS / basic / preferred / lifecycle). Click any product photo to zoom.++The recommendation banner at the top calls out the winning vendor with the reason — the same policy as the CLI, rendered visually.++## Pin-1 alignment check — moved to `adom-chipfit`++The footprint ↔ 3D-chip validator that used to live here as+`adom-parts-search align` is now its own app: **`adom-chipfit`**.+Scope got big enough (package-family guard, seat-Δz classifier,+persistent pin-1 registration bake) that keeping it inside+parts-search was mixing two unrelated concerns.++Install one-liner (paste-into-Claude style):++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-chipfit/adom-chipfit \+ -o /tmp/adom-chipfit && chmod +x /tmp/adom-chipfit \+ && sudo install -m 0755 /tmp/adom-chipfit /usr/local/bin/adom-chipfit \+ && adom-chipfit install+```++Then use it via:++```bash+adom-chipfit check --footprint path.kicad_mod --glb path.glb+```++Running `adom-parts-search align ...` now prints a one-time message+pointing at the install command above and exits non-zero so CI+picks it up.++## Why Mouser is preferred++See the companion skill `adom-parts-search` (the gallia one) for the full policy. Short version:++- **40-minute drone delivery** (Mansfield TX → Fort Worth TX, ~15 miles)+- **Near-zero shipping cost** (drone route, Mouser-operated)+- DigiKey wins only when Mouser is out of stock, lead time >14 days, or DigiKey is >30% cheaper on a volume order++## Backend URLs++The three vendor backends are Tier A services. Current URLs:++- Mouser: `https://mouser-xr8t4f5d01bt.adom.cloud`+- DigiKey: `https://digikey-gjuu8l5t69uh.adom.cloud`+- JLCPCB: `https://jlcpcb-wela51osctvp.adom.cloud`++Override with `--mouser-api` / `--digikey-api` / `--jlcpcb-api`, or env vars `MOUSER_API` / `DIGIKEY_API` / `JLCPCB_API`. Canonical source of truth for current slugs is `~/service-watcher/services.json` on the main gallia container.++## Architecture++This app is **client-only** — no service container of its own. It coordinates the three existing vendor backends (adom-mouser / adom-digikey / adom-jlcpcb). Each individual vendor CLI is still available for single-vendor queries.++## Install++Tier A — fetched by `gallia/install.mjs` on every gallia container. Or manually:++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/adom-parts-search \+ -o /tmp/adom-parts-search && chmod +x /tmp/adom-parts-search && \+ sudo install -m 0755 /tmp/adom-parts-search /usr/local/bin/adom-parts-search && \+ adom-parts-search install+```
VERSIONadded+1@@ -0,0 +1 @@+0.1.7
build.shadded+27@@ -0,0 +1,27 @@+#!/usr/bin/env bash+# Local build + install for adom-parts-search.+set -euo pipefail++cd "$(dirname "$0")"++echo "==> cargo build --release"+cargo build --release++BIN=target/release/adom-parts-search+if [ ! -x "$BIN" ]; then+ echo "ERROR: build did not produce $BIN" >&2+ exit 1+fi++DEST=/usr/local/bin/adom-parts-search+echo "==> installing $DEST (sudo)"+sudo install -m 0755 "$BIN" "$DEST"++echo "==> adom-parts-search install (skill + completions)"+adom-parts-search install++echo+echo "Done. Try:"+echo " adom-parts-search --help"+echo " adom-parts-search search 'STM32F103'"+echo " adom-parts-search app"
docs/align-gallery.htmladded+53@@ -0,0 +1,53 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>Part Align — 10-component gallery</title>+<style>+ :root {+ --bg: #0d1117; --surface: #161b22; --border: #30363d;+ --text: #e6edf3; --text-dim: #8b949e; --accent: #00b8b0;+ }+ * { box-sizing: border-box; margin: 0; padding: 0; }+ html, body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }+ .bar { padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; gap: 16px; align-items: center; }+ .bar h1 { font-size: 14px; font-weight: 600; }+ .bar .sub { color: var(--text-dim); font-size: 12px; }+ .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 6px; background: #05070a; }+ .cell { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--surface); }+ .cell .label { padding: 4px 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); background: var(--surface); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; }+ .cell iframe { flex: 1; border: 0; min-height: 320px; background: #05070a; }+</style>+</head>+<body>+<div class="bar">+ <h1>Part Align — 10-component gallery</h1>+ <span class="sub">one window · 10 iframes · ports 8901–8910</span>+</div>+<div class="grid" id="g"></div>+<script>+ const proxy = 'https://john-galliaapril-8v0y8o3547h2.adom.cloud/proxy';+ const entries = [+ ['DIP-8', 8901],+ ['DIP-16', 8902],+ ['TSSOP-14', 8903],+ ['LQFP-48', 8904],+ ['LQFP-32', 8905],+ ['LQFP-100', 8906],+ ['QFN-24 (mismatch)', 8907],+ ['SOIC-8', 8908],+ ['SOIC-16', 8909],+ ['SOT-23', 8910],+ ];+ const g = document.getElementById('g');+ for (const [name, port] of entries) {+ const cell = document.createElement('div');+ cell.className = 'cell';+ cell.innerHTML = `+ <div class="label"><span>${name}</span><span style="color:var(--text-dim)">port ${port}</span></div>+ <iframe src="${proxy}/${port}/" loading="eager" allow="cross-origin-isolated"></iframe>`;+ g.appendChild(cell);+ }+</script>+</body>+</html>
docs/icon.svgadded+14@@ -0,0 +1,14 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">+ <!-- Monochrome white per brand/SKILL.md + app-creator §1b. Every stroke ++ fill is #e6edf3 or transparent; no brand teal anywhere. -->+ <g fill="none" stroke="#e6edf3" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+ <!-- chip body -->+ <rect x="10" y="14" width="28" height="28" rx="3"/>+ <!-- chip pins (left/right/top/bottom) -->+ <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+ </g>+ <!-- magnifying lens overlay (filled with the dark bg so the chip shows+ through correctly; lens ring + handle are monochrome white) -->+ <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="#e6edf3" stroke-width="4"/>+ <line x1="50" y1="48" x2="58" y2="56" stroke="#e6edf3" stroke-width="5" stroke-linecap="round"/>+</svg>
docs/thumbnail.pngadded⋯ 1 unchanged line ⋯
docs/thumbnail.svgadded+28@@ -0,0 +1,28 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">+ <rect width="400" height="400" fill="#0d1117"/>+ <!-- Three vendor stripes fanning out from a single search point -->+ <g transform="translate(200,200)">+ <!-- Mouser (blue, preferred, thicker & taller) -->+ <rect x="-130" y="-30" width="80" height="140" rx="8" fill="#0073CF" opacity="0.9"/>+ <text x="-90" y="20" text-anchor="middle" font-family="Inter, sans-serif" font-size="16" font-weight="700" fill="#ffffff">MOU</text>+ <text x="-90" y="40" text-anchor="middle" font-family="Inter, sans-serif" font-size="10" font-weight="600" fill="#ffffff" opacity="0.85">SER</text>+ <rect x="-130" y="-48" width="80" height="18" rx="4" fill="#facc15"/>+ <text x="-90" y="-34" text-anchor="middle" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#0d1117">WINNER</text>++ <!-- DigiKey (red, alternative) -->+ <rect x="-40" y="0" width="80" height="110" rx="8" fill="#CC0000" opacity="0.9"/>+ <text x="0" y="50" text-anchor="middle" font-family="Inter, sans-serif" font-size="16" font-weight="700" fill="#ffffff">DIGI</text>+ <text x="0" y="70" text-anchor="middle" font-family="Inter, sans-serif" font-size="10" font-weight="600" fill="#ffffff" opacity="0.85">KEY</text>++ <!-- JLCPCB (green, library) -->+ <rect x="50" y="20" width="80" height="90" rx="8" fill="#57A84A" opacity="0.9"/>+ <text x="90" y="62" text-anchor="middle" font-family="Inter, sans-serif" font-size="15" font-weight="700" fill="#ffffff">JLC</text>+ <text x="90" y="80" text-anchor="middle" font-family="Inter, sans-serif" font-size="10" font-weight="600" fill="#ffffff" opacity="0.85">PCB</text>++ <!-- Magnifier over the cluster -->+ <circle cx="-90" cy="80" r="36" fill="none" stroke="#e6edf3" stroke-width="4"/>+ <line x1="-65" y1="105" x2="-44" y2="126" stroke="#e6edf3" stroke-width="5" stroke-linecap="round"/>+ </g>+ <text x="200" y="345" text-anchor="middle" font-family="Inter, sans-serif" font-size="18" font-weight="700" fill="#e6edf3" letter-spacing="1">PARTS SEARCH</text>+ <text x="200" y="370" text-anchor="middle" font-family="Inter, sans-serif" font-size="11" fill="#8b949e">3 vendors · one query</text>+</svg>
package.json+2−6@@ -2,10 +2,6 @@ "slug": "adom-parts-search", "version": "1.0.0", "type": "app",- "description": "Unified parts search across Mouser + DigiKey + JLCPCB in parallel. Side-by-side comparison with product photos. Mouser-preferred routing (40-min drone to Fort Worth, near-zero shipping).",- "dependencies": {},- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- }+ "description": "One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.",+ "dependencies": {} }
page.json+41−26@@ -3,10 +3,43 @@ "type": "app", "slug": "adom-parts-search", "title": "Adom Parts Search",- "brief": "Unified parts search across Mouser + DigiKey + JLCPCB in parallel. Side-by-side comparison with product photos. Mouser-preferred routing (40-min drone to Fort Worth, near-zero shipping).",- "version": "0.1.7",- "tags": [],+ "brief": "One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.",+ "version": "1.0.0",+ "install": {+ "binary_name": "adom-parts-search",+ "install_dir": "",+ "install_hint": "adompkg install adom-parts-search",+ "version_cmd": "adom-parts-search --version"+ },+ "author": {+ "id": "6738b00a4dc87b8adbb0a5a3",+ "name": "John Lauer",+ "email": "[email protected]"+ },+ "tags": [+ "parts-search",+ "mouser",+ "digikey",+ "jlcpcb",+ "bom",+ "sourcing",+ "distributor",+ "electronics"+ ], "license": "MIT",+ "visibility": {+ "public": true+ },+ "hero": {+ "path": "screenshots/hero.png",+ "type": "image"+ },+ "sample_prompts": [+ "source an STM32F103RBT6",+ "where can I get an ESP32-S3-WROOM cheapest and fastest",+ "compare Mouser, DigiKey and JLCPCB for an LM555",+ "price this BOM across distributors"+ ], "discovery_triggers": [ "parts search", "find part",@@ -22,26 +55,8 @@ "parts availability", "fastest delivery" ],- "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel with product photos; Mouser-preferred routing for 40-min drone delivery from Mansfield to our Fort Worth facility.",- "install": {- "binary_name": "adom-parts-search",- "install_dir": "",- "install_hint": "",- "version_cmd": ""- },- "readme": "# adom-parts-search\n\nParallel parts search across **Mouser + DigiKey + JLCPCB**, rendered side-by-side with product photos. Uses the existing vendor CLIs (`adom-mouser`, `adom-digikey`, `adom-jlcpcb`) as primitives and applies the Adom Mouser-preferred routing policy.\n\n\n\n## Why Mouser is preferred\n\n- **40-minute drone delivery** from Mouser's Mansfield, TX fulfillment center to our Fort Worth facility (~15 miles)\n- **Near-zero shipping cost** (drone route, Mouser-operated)\n- DigiKey is only picked when Mouser is out of stock, lead time is >14 days, or DigiKey is >30% cheaper on volume\n\n## Use\n\n```bash\n# CLI (JSON on stdout, unified across all 3 vendors)\nadom-parts-search search \"iCE40HX4K-TQ144\" --limit 5\n\n# Hydrogen webview — 3-column grid with product photos, buy/datasheet links, zoom\nadom-parts-search app\n```\n\n## Architecture\n\nClient-only, no service container of its own. Coordinates three existing Tier A vendor backends:\n\n```\n any user container\n │\n ▼\n adom-parts-search CLI / app\n │ (3 parallel HTTP POSTs)\n ├──▶ mouser-xr8t4f5d01bt.adom.cloud (adom-mouser)\n ├──▶ digikey-gjuu8l5t69uh.adom.cloud (adom-digikey)\n └──▶ jlcpcb-wela51osctvp.adom.cloud (adom-jlcpcb)\n```\n\nEach vendor backend self-heals via its own cron watchdog (every 2 min). This app just aggregates + renders.\n\n## Install\n\nTier A — `gallia/install.mjs` pulls it from the wiki on every gallia container. Or run the paste-install one-liner on the wiki page.\n\n## Source\n\n[adom-inc/adom-parts-search](https://github.com/adom-inc/adom-parts-search) (private).\n",- "author": {- "id": "695820315b5f1e4db2fcf602",- "name": "Kyle Bergstedt",- "email": "[email protected]"- },- "visibility": {- "public": true- },- "hero": null,- "sample_prompts": [],- "metadata": {},- "created_at": "2026-05-28T05:28:37.840Z",- "updated_at": "2026-05-28T05:28:37.840Z",- "skills": []-}+ "discovery_pitch": "Search Mouser + DigiKey + JLCPCB in parallel from one interface, with product photos and a Mouser-preferred recommendation for 40-min drone delivery from Mansfield to our Fort Worth facility.",+ "metadata": {+ "platform": "linux"+ }+}
publish.shadded+147@@ -0,0 +1,147 @@+#!/usr/bin/env bash+# adom-parts-search release publisher.+# Syncs VERSION → Cargo.toml → git tag → gh release → wiki asset → wiki page.+# Idempotent: safe to re-run for the same version (adom-wiki upload replaces).++set -euo pipefail+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+if [ -z "$VERSION" ]; then+ echo "ERROR: VERSION file is empty" >&2; exit 1+fi++# ANSI only when stdout is a real terminal (adom-cli-design rule:+# no raw escapes when the AI captures stdout).+if [ -t 1 ]; then C_OK='\033[32m'; C_ERR='\033[31m'; C_STEP='\033[36m'; C_RESET='\033[0m'+else C_OK=''; C_ERR=''; C_STEP=''; C_RESET=''+fi+ok() { printf '%sOK:%s %s\n' "$C_OK" "$C_RESET" "$1"; }+fail() { printf '%sERROR:%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; exit 1; }+step() { printf '\n%s== %s ==%s\n' "$C_STEP" "$1" "$C_RESET"; }++# ── 0. Sanity: VERSION == Cargo.toml version ───────────────────────+step "0. sanity checks"+CARGO_VERSION=$(grep -m1 '^version =' Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')+if [ "$VERSION" != "$CARGO_VERSION" ]; then+ fail "VERSION ($VERSION) != Cargo.toml ($CARGO_VERSION). Run: sed -i 's/^version = .*/version = \"$VERSION\"/' Cargo.toml"+fi+ok "VERSION + Cargo.toml both at $VERSION"++# ── 0a. Icon audit — monochrome white, no favicon carve-out ────────+# Gates the release. Per brand/SKILL.md + app-creator §1b, every icon+# (including docs/icon.svg → tab-strip favicon) must be monochrome.+step "0a. icon monochrome-white audit"+TEAL_HITS=$(grep -nrE 'fill="(#00b8b0|#00d4cb)"|stroke="(#00b8b0|#00d4cb)"' \+ docs/icon.svg src/app/ 2>/dev/null || true)+if [ -n "$TEAL_HITS" ]; then+ printf '%s\n' "$TEAL_HITS"+ fail "teal fill/stroke found in an icon/SVG — see brand/SKILL.md §Icons"+fi+ICON_COLORS=$(grep -oE 'fill="#[0-9a-fA-F]{3,8}"|stroke="#[0-9a-fA-F]{3,8}"' docs/icon.svg 2>/dev/null \+ | grep -v -E '"#e6edf3"|"#ffffff"|"#fff"|"#0d1117"|"none"|"transparent"|"currentColor"' || true)+if [ -n "$ICON_COLORS" ]; then+ printf '%s\n' "$ICON_COLORS"+ fail "docs/icon.svg uses disallowed color(s) — must be #e6edf3 / currentColor / none / #0d1117"+fi+LOGO_HITS=$(grep -nrE '\.logo[^{]*\{[^}]*color:\s*var\(--accent\)|\.icon[^{]*\{[^}]*color:\s*var\(--accent\)' \+ src/app/ 2>/dev/null || true)+if [ -n "$LOGO_HITS" ]; then+ printf '%s\n' "$LOGO_HITS"+ fail ".logo / .icon CSS rule inheriting --accent — icon will paint teal at runtime"+fi+ok "all icons monochrome white / currentColor"++# ── 1. Build + verify binary --version ─────────────────────────────+step "1. cargo build --release"+cargo build --release+BIN=target/release/adom-parts-search+BIN_VER=$("$BIN" --version | awk '{print $NF}')+if [ "$BIN_VER" != "$VERSION" ]; then+ fail "binary --version ($BIN_VER) != VERSION ($VERSION)"+fi+ok "binary prints adom-parts-search $VERSION"++# ── 2. Git commit + tag + push ─────────────────────────────────────+step "2. git commit + tag v$VERSION"+git add -A+if ! git diff --cached --quiet; then+ git commit -m "release v$VERSION"+ ok "committed"+else+ ok "nothing to commit"+fi+git tag -f "v$VERSION"+git push origin main+git push origin "v$VERSION" --force+ok "pushed main + v$VERSION tag"++# ── 3. GitHub Release (optional, informational) ────────────────────+step "3. gh release create v$VERSION"+if gh release view "v$VERSION" >/dev/null 2>&1; then+ gh release upload "v$VERSION" "$BIN" --clobber+ ok "updated release v$VERSION binary"+else+ gh release create "v$VERSION" "$BIN" --title "v$VERSION" --generate-notes+ ok "created release v$VERSION"+fi++# ── 4. Wiki page publish/update ────────────────────────────────────+step "4. adom-wiki page publish apps/adom-parts-search"+TITLE=$(jq -r '.title' wiki/page.json)+BRIEF=$(jq -r '.brief' wiki/page.json)+adom-wiki page publish apps/adom-parts-search \+ --title "$TITLE" \+ --brief "$BRIEF" \+ --body-md wiki/body.md \+ --version "$VERSION" \+ --sample-prompt "Parts search|find me a USB-C connector on Mouser, DigiKey, or JLCPCB with stock > 1000" \+ --sample-prompt "Compare distributors|compare prices and stock for STM32F103RBT6 across Mouser, DigiKey, and JLCPCB" \+ --sample-prompt "Open parts search|open the Adom Parts Search app in Hydrogen so I can see product photos" \+ --sample-prompt "Best deal|find the best price for iCE40HX4K-TQ144 accounting for Mouser's 40-minute drone delivery to Fort Worth" \+ --sample-prompt "Check stock|where is the ESP32-S3-WROOM-1U-N4R2 in stock fastest?"+ok "wiki page published at apps/adom-parts-search v$VERSION"++# ── 5. Wiki asset upload (canonical install surface) ───────────────+step "5. adom-wiki asset upload (docker-binary + skill + build-skill)"+adom-wiki asset upload apps/adom-parts-search --asset-type docker-binary --file "$BIN"+ok "uploaded $BIN to wiki at apps/adom-parts-search"+# Skills live on the wiki so prose-only edits ship without a binary rebuild+# (see adom-cli-design "DO NOT embed SKILL.md in the binary" rule).+adom-wiki asset upload apps/adom-parts-search --asset-type file --file SKILL.md+adom-wiki asset upload apps/adom-parts-search --asset-type file --file BUILD-SKILL.md+ok "uploaded SKILL.md + BUILD-SKILL.md to wiki"++# ── 6. Push metadata (discovery_triggers, install_hint, releases) ──+step "6. adom-wiki page edit --field metadata"+TMP_META=$(mktemp)+jq --arg v "$VERSION" '.metadata | .version = $v' wiki/page.json > "$TMP_META"+adom-wiki page edit apps/adom-parts-search --field metadata --body-md "$TMP_META"+rm -f "$TMP_META"+ok "metadata updated"++# ── 7. Verify the wiki actually serves v$VERSION ───────────────────+step "7. verify wiki binary --version"+BINARY_URL=$(jq -r '.metadata.releases.adom_docker.binary_url' wiki/page.json)+if [ -z "$BINARY_URL" ] || [ "$BINARY_URL" = "null" ]; then+ fail "wiki page.json has no binary_url"+fi+CHECK=$(mktemp)+if ! curl -fsSL "$BINARY_URL" -o "$CHECK"; then+ rm -f "$CHECK"+ fail "wiki binary download failed at $BINARY_URL (may need a minute to propagate; re-run step 7)"+fi+chmod +x "$CHECK"+WIKI_VER=$("$CHECK" --version | awk '{print $NF}')+rm -f "$CHECK"+if [ "$WIKI_VER" != "$VERSION" ]; then+ fail "wiki serves $WIKI_VER but we just published $VERSION"+fi+ok "wiki binary returns --version adom-parts-search $VERSION"++echo+echo "───────────────────────────────────────────────"+echo " adom-parts-search v$VERSION published."+echo " Wiki: $BINARY_URL"+echo " The service-parts-search container will auto-update within 2 minutes."+echo "───────────────────────────────────────────────"
screenshots/cli.pngadded⋯ 1 unchanged line ⋯
screenshots/compare-grid.pngadded⋯ 1 unchanged line ⋯
screenshots/hero.pngadded⋯ 1 unchanged line ⋯
screenshots/policy-switch.pngadded⋯ 1 unchanged line ⋯
src/app/mod.rsadded+207@@ -0,0 +1,207 @@+//! `adom-parts-search app` — Hydrogen webview showing unified parts search+//! across all three vendors in a 3-column grid with product photos.+//!+//! The app owns its own HTTP server (tiny_http). When the UI POSTs to+//! /search, the server delegates to vendors::search_all which fires+//! three parallel HTTP calls to the existing vendor backends.++pub mod tab;++use crate::vendors;+use crate::{note, ok, warn, AppArgs};+use serde_json::{json, Value};+use std::collections::VecDeque;+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Response, Server};++const UI_HTML: &str = include_str!("ui.html");+const ICON_SVG: &str = include_str!("../../docs/icon.svg");+const CONSOLE_MAX: usize = 500;++struct AppState {+ mouser_api: Option<String>,+ digikey_api: Option<String>,+ jlcpcb_api: Option<String>,+ dev: bool,+ console: Mutex<VecDeque<Value>>,+ last_query: Mutex<Option<String>>,+ last_results: Mutex<Option<Value>>,+ last_query_ts: Mutex<u128>,+ pending_eval: Mutex<Option<(String, String)>>,+ eval_results: Mutex<std::collections::HashMap<String, Value>>,+}++fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {+ // Every response — HTML, JSON, SVG — carries the embed-friendly CSP+ // so Hydrogen's "Some sites block embedding" warning doesn't trip on+ // any request it sniffs (it probes more than just /).+ Response::from_string(body.to_string())+ .with_status_code(code)+ .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+ .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+ .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap())+}++fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {+ use std::io::Read;+ let mut body = String::new();+ req.as_reader().read_to_string(&mut body).map_err(|e| format!("read body: {}", e))?;+ if body.is_empty() { return Ok(Value::Null); }+ serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))+}++pub fn run(args: AppArgs) -> Result<(), String> {+ ok(format!("adom-parts-search app starting on port {}", args.port));+ note!(" backends:");+ note!(" mouser: {}", args.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()));+ note!(" digikey: {}", args.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()));+ note!(" jlcpcb: {}", args.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()));++ let state = Arc::new(AppState {+ mouser_api: args.mouser_api.clone(),+ digikey_api: args.digikey_api.clone(),+ jlcpcb_api: args.jlcpcb_api.clone(),+ dev: args.dev,+ console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),+ last_query: Mutex::new(None),+ last_results: Mutex::new(None),+ last_query_ts: Mutex::new(0),+ pending_eval: Mutex::new(None),+ eval_results: Mutex::new(std::collections::HashMap::new()),+ });++ let addr = format!("0.0.0.0:{}", args.port);+ let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;++ if !args.no_tab {+ let proxy_url = tab::proxy_url(args.port);+ match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {+ Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),+ Err(e) => warn(format!("couldn't open tab: {}. Manually: {}", e, proxy_url)),+ }+ }+ note!(" {}Press ctrl-c or POST /shutdown to stop.{}", crate::DIM, crate::RESET);++ for mut req in server.incoming_requests() {+ let url = req.url().to_string();+ let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();+ let method = req.method().clone();+ let state_ref = Arc::clone(&state);++ let result: (u16, Value) = match (method.clone(), path.as_str()) {+ (Method::Get, "/") | (Method::Get, "/index.html") => {+ // Explicitly declare we are embed-safe so Hydrogen's webview+ // doesn't show its "Some sites block embedding" banner. We+ // allow framing from any origin (the container's Coder proxy+ // hostname varies per user) but require same-origin for+ // everything else. No `X-Frame-Options` — modern browsers+ // prefer `frame-ancestors` and emitting both can contradict.+ let _ = req.respond(+ Response::from_string(UI_HTML).with_status_code(200)+ .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())+ .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+ .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),+ );+ continue;+ }+ (Method::Get, "/favicon.svg") => {+ let _ = req.respond(+ Response::from_string(ICON_SVG).with_status_code(200)+ .with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())+ .with_header(Header::from_bytes("Content-Security-Policy", "frame-ancestors *").unwrap()),+ );+ continue;+ }+ (Method::Get, "/state") => {+ let q = state_ref.last_query.lock().unwrap().clone();+ let r = state_ref.last_results.lock().unwrap().clone();+ let ts = *state_ref.last_query_ts.lock().unwrap();+ (200, json!({+ "dev": state_ref.dev,+ "last_query": q,+ "last_query_ts": ts as u64,+ "last_results": r,+ "backends": {+ "mouser": state_ref.mouser_api.clone().unwrap_or(vendors::DEFAULT_MOUSER.into()),+ "digikey": state_ref.digikey_api.clone().unwrap_or(vendors::DEFAULT_DIGIKEY.into()),+ "jlcpcb": state_ref.jlcpcb_api.clone().unwrap_or(vendors::DEFAULT_JLCPCB.into()),+ }+ }))+ }+ (Method::Post, "/search") => match read_body(&mut req) {+ Ok(body) => {+ let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32;+ let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);+ if keyword.trim().is_empty() {+ (400, json!({"error": "keyword required"}))+ } else {+ let agg = vendors::search_all(+ &keyword, limit, in_stock,+ state_ref.mouser_api.as_deref(),+ state_ref.digikey_api.as_deref(),+ state_ref.jlcpcb_api.as_deref(),+ );+ let v = serde_json::to_value(&agg).unwrap_or(json!({}));+ *state_ref.last_query.lock().unwrap() = Some(keyword);+ *state_ref.last_results.lock().unwrap() = Some(v.clone());+ *state_ref.last_query_ts.lock().unwrap() = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis()).unwrap_or(0);+ (200, v)+ }+ }+ Err(e) => (400, json!({"error": e})),+ },+ (Method::Post, "/console") => match read_body(&mut req) {+ Ok(v) => {+ let mut c = state_ref.console.lock().unwrap();+ if c.len() >= CONSOLE_MAX { c.pop_front(); }+ c.push_back(v);+ (200, json!({"ok": true}))+ }+ Err(e) => (400, json!({"error": e})),+ },+ (Method::Get, "/console") => {+ let c: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();+ (200, json!({"messages": c}))+ }+ (Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {+ Ok(v) => {+ let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let id = format!("ev{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos());+ *state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));+ (200, json!({"id": id}))+ }+ Err(e) => (400, json!({"error": e})),+ },+ (Method::Get, "/eval/pending") if state_ref.dev => {+ let p = state_ref.pending_eval.lock().unwrap().take();+ match p {+ Some((id, code)) => (200, json!({"id": id, "code": code})),+ None => (200, json!({})),+ }+ }+ (Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {+ let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();+ match read_body(&mut req) {+ Ok(v) => {+ let r = v.get("result").cloned().unwrap_or(Value::Null);+ state_ref.eval_results.lock().unwrap().insert(id, r);+ (200, json!({"ok": true}))+ }+ Err(e) => (400, json!({"error": e})),+ }+ }+ (Method::Post, "/shutdown") => {+ if !args.no_tab { let _ = tab::remove_webview_tab(&args.tab_name); }+ let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));+ ok("shutdown requested, exiting");+ std::process::exit(0);+ }+ _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),+ };+ let _ = req.respond(json_resp(result.0, result.1));+ }+ Ok(())+}
src/app/tab.rsadded+134@@ -0,0 +1,134 @@+//! Hydrogen tab lifecycle via `adom-cli hydrogen workspace`.++use base64::Engine;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++/// Substitute the current port into VSCODE_PROXY_URI so the webview+/// loads via the Coder proxy (same-origin with hydrogen.adom.inc).+/// Falls back to 127.0.0.1 for local testing when the env var is not+/// set — `adom-parts-search app --no-tab` + `$ open http://127.0.0.1:8893`.+pub fn proxy_url(port: u16) -> String {+ match std::env::var("VSCODE_PROXY_URI") {+ Ok(tmpl) => tmpl.replace("{{port}}", &port.to_string()),+ Err(_) => format!("http://127.0.0.1:{}/", port),+ }+}++fn icon_data_url(icon_svg: &str) -> String {+ let b64 = base64::engine::general_purpose::STANDARD.encode(icon_svg.as_bytes());+ format!("data:image/svg+xml;base64,{}", b64)+}++pub fn add_webview_tab(name: &str, url: &str, icon_svg: &str) -> Result<(), String> {+ // Find the focused leaf we should attach to.+ let leaf = focused_leaf_for_tab()?;+ let initial_state = serde_json::json!({ "url": url }).to_string();+ let icon = icon_data_url(icon_svg);+ let out = Command::new("adom-cli")+ .args([+ "hydrogen",+ "workspace",+ "add-tab",+ "--panel-id",+ &leaf,+ "--panel-type",+ WEBVIEW_PANEL_TYPE,+ "--display-name",+ name,+ "--display-icon",+ &icon,+ "--initial-state",+ &initial_state,+ ])+ .output()+ .map_err(|e| format!("adom-cli add-tab: {}", e))?;+ if !out.status.success() {+ return Err(format!(+ "adom-cli add-tab failed: {}",+ String::from_utf8_lossy(&out.stderr)+ ));+ }+ Ok(())+}++pub fn remove_webview_tab(name: &str) -> Result<(), String> {+ let out = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "remove-tab", "--name", name])+ .output()+ .map_err(|e| format!("adom-cli remove-tab: {}", e))?;+ if !out.status.success() {+ return Err(format!(+ "adom-cli remove-tab failed: {}",+ String::from_utf8_lossy(&out.stderr)+ ));+ }+ Ok(())+}++/// Pick a leaf panel that is NOT the VS Code editor, so we don't cover+/// the user's code view. Walk the workspace tree, find nodes with+/// `type: "leaf"`, check whether any tab in the leaf is a VS Code+/// editor (`...eeee-4000-a000-...`). Return the first non-editor+/// leaf's id. Falls back to the focused panel id if nothing else fits.+fn focused_leaf_for_tab() -> Result<String, String> {+ let out = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "get"])+ .output()+ .map_err(|e| format!("adom-cli workspace get: {}", e))?;+ if !out.status.success() {+ return Err(format!("adom-cli workspace get failed: {}", String::from_utf8_lossy(&out.stderr)));+ }+ let v: serde_json::Value = serde_json::from_slice(&out.stdout)+ .map_err(|e| format!("parse workspace JSON: {}", e))?;++ let mut leaves: Vec<LeafInfo> = Vec::new();+ walk_leaves(&v, &mut leaves);++ // Prefer leaves that contain no VS Code editor tabs.+ let non_editor: Vec<&LeafInfo> = leaves.iter().filter(|l| !l.has_editor).collect();+ if let Some(l) = non_editor.first() {+ return Ok(l.id.clone());+ }+ if let Some(id) = v.get("focusedPanelId").and_then(|v| v.as_str()) {+ return Ok(id.to_string());+ }+ leaves+ .first()+ .map(|l| l.id.clone())+ .ok_or_else(|| "no panels found in workspace".into())+}++struct LeafInfo {+ id: String,+ has_editor: bool,+}++fn walk_leaves(v: &serde_json::Value, out: &mut Vec<LeafInfo>) {+ if let Some(obj) = v.as_object() {+ if obj.get("type").and_then(|v| v.as_str()) == Some("leaf") {+ if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {+ let tabs = obj.get("tabs").and_then(|v| v.as_array());+ let has_editor = tabs+ .map(|arr| {+ arr.iter().any(|t| {+ let pt = t.get("panelType").and_then(|v| v.as_str()).unwrap_or("");+ // VS Code editor panel UUID has "-eeee-4000-a000-"+ // or "-0030-4000-a000-". Web View is "-0031-".+ pt.contains("eeee-4000") || pt.contains("0030-4000")+ })+ })+ .unwrap_or(false);+ out.push(LeafInfo { id: id.to_string(), has_editor });+ }+ }+ for (_, child) in obj {+ walk_leaves(child, out);+ }+ } else if let Some(arr) = v.as_array() {+ for c in arr {+ walk_leaves(c, out);+ }+ }+}
src/app/ui.htmladded+455@@ -0,0 +1,455 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1">+<title>Adom Parts Search</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@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: '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;+}++:root {+ --bg: #0d1117; --surface: #161b22; --surface2: #1c2129;+ --border: #30363d; --text: #e6edf3; --text-dim: #8b949e;+ --accent: #00b8b0;+ --green: #3fb950; --red: #f85149; --yellow: #d29922; --blue: #58a6ff;+ --mouser: #0073CF; --digikey: #CC0000; --jlcpcb: #57A84A;+}+* { box-sizing: border-box; margin: 0; padding: 0; }+html, body {+ background: var(--bg); color: var(--text);+ font-family: 'Satoshi', sans-serif; font-size: 14px; line-height: 1.55;+ min-height: 100vh; overflow-x: hidden;+}+body { padding: 20px 24px 80px; }+h1 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 22px; letter-spacing: -0.01em; }++.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }+.hdr .logo { width: 34px; height: 34px; color: var(--text); display: inline-flex; }+.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }++.searchbar {+ display: flex; gap: 10px; align-items: stretch; margin-bottom: 10px;+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;+}+.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,0.15); }+.searchbar input {+ flex: 1; background: transparent; border: none; outline: none; color: var(--text);+ font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;+}+.searchbar input::placeholder { color: var(--text-dim); }+.limit-select {+ background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;+ color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;+ font-family: 'JetBrains Mono', monospace;+}+.btn { padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 14px; }+.btn.primary { background: var(--accent); color: #0d1117; }+.btn.primary:hover { background: #00d4cb; }+.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }+.filters { display: flex; gap: 14px; align-items: center; color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }+.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }+.filters input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; }++.recommendation {+ display: flex; align-items: center; gap: 14px;+ background: linear-gradient(90deg, rgba(0,184,176,0.10), rgba(0,184,176,0.02));+ border: 1px solid rgba(0,184,176,0.35);+ border-radius: 12px; padding: 14px 18px; margin-bottom: 20px;+}+.recommendation .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); font-weight: 600; }+.recommendation .main { font-family: 'Familjen Grotesk', sans-serif; font-size: 17px; font-weight: 600; }+.recommendation .reason { color: var(--text-dim); font-size: 13px; margin-top: 2px; }+.recommendation.empty { background: linear-gradient(90deg, rgba(210,153,34,0.10), rgba(210,153,34,0.02)); border-color: rgba(210,153,34,0.35); }+.recommendation.empty .label { color: var(--yellow); }+.recommendation.empty .es-hint { color: var(--text); font-size: 12.5px; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }+.recommendation.empty .es-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }++.statusline { color: var(--text-dim); font-size: 13px; margin: 4px 0 14px; min-height: 20px; }+.statusline.error { color: var(--red); }+.statusline.ok { color: var(--green); }+.loading { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; }+@keyframes spin { to { transform: rotate(360deg); } }++/* Auto-fit: always fit as many columns as will fit at ≥320 px each.+ Narrow panes get 1-col, medium 2-col, wide 3-col — no hard breakpoint+ that drops us from 3 straight to 1 (the previous 1100 px rule did+ that, hiding the other two columns below a horizontal scroll in a+ split Hydrogen pane). */+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }++.column { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; min-height: 240px; display: flex; flex-direction: column; }+.column.mouser { border-top: 3px solid var(--mouser); }+.column.digikey { border-top: 3px solid var(--digikey); }+.column.jlcpcb { border-top: 3px solid var(--jlcpcb); }+.column.preferred { box-shadow: 0 0 0 2px rgba(0,184,176,0.35), 0 12px 40px rgba(0,184,176,0.10); }++.col-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }+.col-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 19px; font-weight: 600; }+.col-title.mouser { color: var(--mouser); }+.col-title.digikey { color: var(--digikey); }+.col-title.jlcpcb { color: var(--jlcpcb); }+.col-meta { color: var(--text-dim); font-size: 11px; font-family: 'JetBrains Mono', monospace; }+.col-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: rgba(0,184,176,0.15); color: var(--accent); border: 1px solid rgba(0,184,176,0.3); }++.card { display: grid; grid-template-columns: 72px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--border); }+.card:last-child { border-bottom: none; }+.card-img { width: 72px; height: 72px; border-radius: 8px; background: #fff; display: flex; align-items: center; justify-content: center; overflow: hidden; cursor: zoom-in; flex-shrink: 0; }+.card-img img { width: 100%; height: 100%; object-fit: contain; }+.card-img .placeholder { color: #6b7380; font-size: 10px; }+.card-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; }+.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; font-weight: 600; word-break: break-word; }+.vendor-pn { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }+.manuf { color: var(--text-dim); font-size: 11px; }+.desc { color: var(--text); font-size: 12px; line-height: 1.4; margin-top: 2px; }+.row-price { display: flex; gap: 10px; font-size: 12px; margin-top: 4px; align-items: baseline; flex-wrap: wrap; }+.price { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; }+.price-100 { font-family: 'JetBrains Mono', monospace; color: var(--text-dim); font-size: 11px; }+.stock.high { color: var(--green); }+.stock.medium { color: var(--yellow); }+.stock.low { color: var(--red); }+.stock.zero { color: var(--text-dim); }+.flags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }+.flag { font-size: 10px; font-family: 'JetBrains Mono', monospace; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }+.flag.basic { color: var(--green); border-color: rgba(63,185,80,0.3); }+.flag.preferred { color: var(--blue); border-color: rgba(88,166,255,0.3); }+.card-actions { margin-top: 6px; display: flex; gap: 8px; }+.card-actions a { font-size: 11px; color: var(--accent); text-decoration: none; padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); }+.card-actions a:hover { border-color: var(--accent); }+.xref-badge { font-size: 9px; padding: 1px 4px; border-radius: 3px; background: var(--surface); color: var(--text-dim); margin-left: 4px; border: 1px solid var(--border); vertical-align: baseline; }++.col-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; font-style: italic; padding: 30px 10px; text-align: center; }+.col-error { padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; }+.col-error .ce-title { display: flex; align-items: center; gap: 8px; color: var(--yellow); font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 14px; line-height: 1.3; }+.col-error .ce-icon { width: 18px; height: 18px; flex-shrink: 0; color: var(--yellow); }+.col-error .ce-body { color: var(--text); font-size: 12.5px; line-height: 1.5; }+.col-error .ce-hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; padding-top: 4px; border-top: 1px dashed var(--border); }+.col-error .ce-hint::before { content: "→ "; color: var(--accent); font-weight: 600; }++.lightbox { position: fixed; inset: 0; z-index: 100000; background: rgba(13,17,23,0.92); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.18s ease; cursor: zoom-out; }+.lightbox.open { opacity: 1; pointer-events: auto; }+.lightbox img { max-width: min(90vw, 1200px); max-height: 86vh; border-radius: 12px; box-shadow: 0 24px 80px rgba(0,0,0,0.7); background: #fff; }+.lb-caption { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); color: var(--text); background: rgba(22,27,34,0.88); padding: 10px 18px; border-radius: 10px; font-size: 13px; border: 1px solid var(--border); max-width: min(90vw, 900px); font-family: 'JetBrains Mono', monospace; }+.lb-close { position: absolute; top: 20px; right: 24px; width: 40px; height: 40px; border-radius: 50%; background: rgba(22,27,34,0.88); border: 1px solid var(--border); color: var(--text); display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; }++[data-tooltip] { position: relative; }+[data-tooltip]::after {+ content: attr(data-tooltip);+ position: absolute;+ bottom: calc(100% + 8px);+ left: 0;+ right: auto;+ width: max-content;+ max-width: min(360px, calc(100vw - 48px));+ white-space: pre-wrap;+ background: rgba(22,27,34,0.96);+ backdrop-filter: blur(16px);+ border: 1px solid rgba(0,184,176,0.2);+ border-radius: 10px;+ padding: 12px 16px;+ font-family: 'Satoshi', sans-serif;+ font-size: 12px;+ line-height: 1.55;+ color: #e6edf3;+ text-align: left;+ pointer-events: none;+ opacity: 0;+ transform: translateY(4px);+ box-shadow: 0 12px 40px rgba(0,0,0,0.55);+ transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;+ z-index: 99999;+}+[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }+[data-tooltip]:hover::after {+ opacity: 1;+ transform: translateY(0);+ transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;+}+</style>+</head>+<body>++<div class="hdr">+ <span class="logo" aria-hidden="true">+ <svg width="30" height="30" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">+ <g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+ <rect x="10" y="14" width="28" height="28" rx="3"/>+ <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+ </g>+ <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>+ <line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>+ </svg>+ </span>+ <div>+ <h1>Adom Parts Search</h1>+ <div class="subtitle">Mouser · DigiKey · JLCPCB — parallel search, Mouser-preferred (40-min drone, Fort Worth ↔ Mansfield, near-zero shipping).</div>+ </div>+</div>++<form id="search-form" class="searchbar" autocomplete="off">+ <input id="q" name="keyword" type="text" placeholder="Search all three vendors: STM32F103RBT6, iCE40HX4K-TQ144, LDO 3.3V 500mA …" autofocus>+ <select id="limit" class="limit-select"+ data-tooltip="How many top hits to fetch per vendor. Each vendor's column shows up to this many product cards; the recommendation banner above only ever ranks the top pick. Set higher (10) when you want to compare variants, lower (3) when you just want a quick snapshot. Higher limits cost more upstream API calls — Mouser's free-tier quota is ~1000/day, so 10 means ~100 searches/day max on Mouser.">+ <option value="3">3</option>+ <option value="5" selected>5</option>+ <option value="10">10</option>+ </select>+ <button type="submit" class="btn primary" id="go"+ data-tooltip="Fires one keyword search in parallel against all three vendor backends (Mouser, DigiKey, JLCPCB) and renders the results as three columns below. Also computes a single recommendation — Mouser wins by default because of the 40-minute drone delivery from Mansfield to our Fort Worth facility and near-zero shipping; DigiKey or JLCPCB only win when Mouser is out of stock, has a long lead time, or is meaningfully more expensive on a volume order."+ data-tooltip-align="right">Search all 3</button>+</form>+<div class="filters">+ <label data-tooltip="When checked, filters out any vendor hits with stock = 0 before the results are shown. Useful when you want to buy today and don't care about backorders or long-lead-time parts. Leave unchecked when you're researching a BOM and want to see all options even if some are out of stock (you might accept a 60-day lead if the part is a perfect fit).">+ <input type="checkbox" id="instock"> In stock only+ </label>+</div>++<div id="status" class="statusline"></div>+<div id="recommendation"></div>++<div id="grid" class="grid">+ <div id="col-mouser" class="column mouser">+ <div class="col-head">+ <span class="col-title mouser">Mouser</span>+ <span class="col-badge">Preferred · 40-min drone · $0 ship</span>+ <span class="col-meta" id="meta-mouser"></span>+ </div>+ <div class="col-body" id="body-mouser"><div class="col-empty">Run a search to see Mouser results.</div></div>+ </div>+ <div id="col-digikey" class="column digikey">+ <div class="col-head">+ <span class="col-title digikey">DigiKey</span>+ <span class="col-meta" id="meta-digikey"></span>+ </div>+ <div class="col-body" id="body-digikey"><div class="col-empty">1–3 day ground ship · alternative when Mouser is out.</div></div>+ </div>+ <div id="col-jlcpcb" class="column jlcpcb">+ <div class="col-head">+ <span class="col-title jlcpcb">JLCPCB</span>+ <span class="col-meta" id="meta-jlcpcb"></span>+ </div>+ <div class="col-body" id="body-jlcpcb"><div class="col-empty">Parts library — for JLCPCB-assembled boards.</div></div>+ </div>+</div>++<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">+ <div class="lb-close" id="lb-close">✕</div>+ <img id="lb-img" alt="">+ <div class="lb-caption" id="lb-caption"></div>+</div>++<script>+(function() {+ const orig = { log: console.log, warn: console.warn, error: console.error };+ function fwd(level, args) {+ orig[level].apply(console, args);+ try {+ fetch('console', { method: 'POST', headers: {'Content-Type':'application/json'},+ body: JSON.stringify({ level, ts: Date.now(),+ message: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ')+ }) }).catch(() => {});+ } catch {}+ }+ console.log = (...a) => fwd('log', a);+ console.warn = (...a) => fwd('warn', a);+ console.error = (...a) => fwd('error', a);+ window.addEventListener('error', e => fwd('error', [e.message, (e.filename||'')+':'+(e.lineno||'')]));+ window.addEventListener('unhandledrejection', e => fwd('error', ['unhandledrejection', String(e.reason)]));+})();++function escape(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }+function fmtStock(n) { if (n >= 1000000) return (n/1000000).toFixed(1)+'M'; if (n >= 1000) return (n/1000).toFixed(1)+'k'; return String(n||0); }+function stockClass(n) { if (!n) return 'zero'; if (n < 1000) return 'low'; if (n < 10000) return 'medium'; return 'high'; }++function renderCard(hit) {+ const flags = (hit.flags||[]).map(f => {+ const cls = ['basic','preferred','extended'].includes(String(f).toLowerCase()) ? String(f).toLowerCase() : '';+ return `<span class="flag ${cls}">${escape(f)}</span>`;+ }).join('');+ const actions = [];+ if (hit.product_url) actions.push(`<a href="${escape(hit.product_url)}" target="_blank" rel="noopener">Buy ↗</a>`);+ if (hit.datasheet_url) actions.push(`<a href="${escape(hit.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a>`);+ if (hit.cad_3d_url) {+ const fmt = (hit.cad_3d_format || '3D').toUpperCase();+ const src = hit.cad_3d_source || 'vendor';+ const xrefBadge = src.includes('xref') ? ' <span class="xref-badge" title="URL came from a DigiKey hit with the same MPN; this vendor doesn\'t publish its own CAD">via DigiKey</span>' : '';+ actions.push(`<a href="${escape(hit.cad_3d_url)}" target="_blank" rel="noopener"+ data-tooltip="Opens the ${escape(fmt)} CAD model for ${escape(hit.mpn || 'this part')}. Most links redirect through SamacSys / Ultra Librarian, which may ask you to sign in before the download starts. Once you've got the STEP file locally, pipe it into \`adom-molecule create --step …\` to bring it into the Adom molecule system."+ >3D ${fmt} ↗${xrefBadge}</a>`);+ }+ return `+ <div class="card">+ <div class="card-img" ${hit.image_url ? `data-zoom="${escape(hit.image_url)}" data-zoom-caption="${escape((hit.mpn||'')+' — '+(hit.manufacturer||''))}"` : ''}>+ ${hit.image_url ? `<img src="${escape(hit.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}+ <div class="placeholder" ${hit.image_url ? 'style="display:none"' : ''}>no image</div>+ </div>+ <div class="card-body">+ <div class="mpn">${escape(hit.mpn || hit.vendor_pn || '—')}</div>+ ${hit.vendor_pn && hit.vendor_pn !== hit.mpn ? `<div class="vendor-pn">${escape(hit.vendor_pn)}</div>` : ''}+ ${hit.manufacturer ? `<div class="manuf">${escape(hit.manufacturer)}</div>` : ''}+ ${hit.description ? `<div class="desc">${escape(hit.description).slice(0,160)}</div>` : ''}+ <div class="row-price">+ ${hit.unit_price ? `<span class="price">${escape(hit.unit_price)}</span>` : ''}+ ${hit.price_100 ? `<span class="price-100">@100: ${escape(hit.price_100)}</span>` : ''}+ <span class="stock ${stockClass(hit.stock)}">${fmtStock(hit.stock)} in stock</span>+ ${hit.lead_time ? `<span class="vendor-pn">lead: ${escape(hit.lead_time)}</span>` : ''}+ </div>+ ${flags ? `<div class="flags">${flags}</div>` : ''}+ ${actions.length ? `<div class="card-actions">${actions.join('')}</div>` : ''}+ </div>+ </div>`;+}++function renderColumn(vendorKey, block) {+ const body = document.getElementById('body-'+vendorKey);+ const meta = document.getElementById('meta-'+vendorKey);+ if (!block || !block.ok) {+ const title = (block && block.error) || 'No response from vendor.';+ const expl = (block && block.error_explanation) || '';+ const hint = (block && block.error_hint) || '';+ body.innerHTML = `+ <div class="col-error">+ <div class="ce-title">+ <svg class="ce-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>+ <span>${escape(title)}</span>+ </div>+ ${expl ? `<div class="ce-body">${escape(expl)}</div>` : ''}+ ${hint ? `<div class="ce-hint">${escape(hint)}</div>` : ''}+ </div>`;+ meta.textContent = block && block.elapsed_ms ? block.elapsed_ms+' ms' : '';+ return;+ }+ meta.textContent = `${block.total||0} total · ${block.elapsed_ms} ms`;+ const hits = block.hits || [];+ body.innerHTML = hits.length ? hits.map(renderCard).join('') : '<div class="col-empty">No matches.</div>';+}++function renderRecommendation(rec, emptyState) {+ const el = document.getElementById('recommendation');+ for (const v of ['mouser','digikey','jlcpcb']) document.getElementById('col-'+v).classList.remove('preferred');+ if (rec) {+ const vc = String(rec.winner).toLowerCase();+ el.innerHTML = `+ <div class="recommendation">+ <div style="flex:1">+ <div class="label">Recommendation</div>+ <div class="main">Order <span style="color:var(--${vc})">${escape(rec.winner)}</span> · <code>${escape(rec.mpn)}</code> ${rec.unit_price ? '· '+escape(rec.unit_price)+'/unit' : ''} · ${fmtStock(rec.stock)} stock</div>+ <div class="reason">${escape(rec.reason)}</div>+ </div>+ </div>`;+ document.getElementById('col-'+vc).classList.add('preferred');+ return;+ }+ if (emptyState) {+ el.innerHTML = `+ <div class="recommendation empty">+ <div style="flex:1">+ <div class="label">No recommendation</div>+ <div class="main">${escape(emptyState.title)}</div>+ <div class="reason">${escape(emptyState.explanation)}</div>+ <div class="es-hint">${escape(emptyState.hint)}</div>+ </div>+ </div>`;+ return;+ }+ el.innerHTML = '';+}++async function doSearch(keyword) {+ if (!keyword.trim()) return;+ const limit = parseInt(document.getElementById('limit').value, 10);+ const inStock = document.getElementById('instock').checked;+ const go = document.getElementById('go');+ go.disabled = true; go.textContent = 'Searching…';+ document.getElementById('status').innerHTML = '<span class="loading"></span> querying Mouser + DigiKey + JLCPCB in parallel…';+ for (const v of ['mouser','digikey','jlcpcb']) {+ document.getElementById('body-'+v).innerHTML = '<div class="col-empty"><span class="loading"></span> searching…</div>';+ document.getElementById('meta-'+v).textContent = '';+ }+ try {+ const r = await fetch('search', { method: 'POST', headers: {'Content-Type':'application/json'},+ body: JSON.stringify({ keyword, limit, inStockOnly: inStock }) });+ const v = await r.json();+ if (!r.ok) {+ const st = document.getElementById('status');+ st.textContent = 'error: '+(v.error||r.statusText); st.className = 'statusline error';+ return;+ }+ renderColumn('mouser', v.mouser);+ renderColumn('digikey', v.digikey);+ renderColumn('jlcpcb', v.jlcpcb);+ renderRecommendation(v.recommendation, v.empty_state);+ const st = document.getElementById('status');+ st.textContent = `Done — Mouser ${v.mouser?.hits?.length||0} · DigiKey ${v.digikey?.hits?.length||0} · JLCPCB ${v.jlcpcb?.hits?.length||0}`;+ st.className = 'statusline ok';+ } catch (e) {+ console.error('search failed (see column error cards for details)');+ const st = document.getElementById('status');+ st.textContent = 'error: '+(e.message||e); st.className = 'statusline error';+ } finally {+ go.disabled = false; go.textContent = 'Search all 3';+ }+}++document.getElementById('search-form').addEventListener('submit', e => {+ e.preventDefault();+ doSearch(document.getElementById('q').value);+});++(function() {+ const lb = document.getElementById('lightbox');+ const img = document.getElementById('lb-img');+ const cap = document.getElementById('lb-caption');+ function open(src, caption) { img.src = src; cap.textContent = caption||''; lb.classList.add('open'); }+ function close() { lb.classList.remove('open'); setTimeout(() => { if (!lb.classList.contains('open')) img.src = ''; }, 200); }+ document.addEventListener('click', e => {+ const z = e.target.closest('[data-zoom]');+ if (z) { e.preventDefault(); open(z.dataset.zoom, z.dataset.zoomCaption); return; }+ if (e.target === lb || e.target.id === 'lb-close') close();+ });+ document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });+})();++// Server state is the single source of truth (app-creator §7a).+// The UI polls /state every 500ms; whenever a new query lands on the+// server (from the form submit, from the CLI's `show` subcommand, or+// from any other client posting to /search), the UI re-renders. This+// is what makes the app CLI-drivable and AI-drivable without any+// URL-param or eval hackery.+let lastSig = null;+async function pollState() {+ try {+ const r = await fetch('state');+ if (!r.ok) return;+ const v = await r.json();+ const sig = v.last_query_ts || (v.last_query ? v.last_query + ':' + JSON.stringify(v.last_results || {}).length : null);+ if (sig === lastSig) return;+ lastSig = sig;+ if (v.last_query) document.getElementById('q').value = v.last_query;+ if (v.last_results) {+ renderColumn('mouser', v.last_results.mouser);+ renderColumn('digikey', v.last_results.digikey);+ renderColumn('jlcpcb', v.last_results.jlcpcb);+ renderRecommendation(v.last_results.recommendation, v.last_results.empty_state);+ }+ } catch {}+}+pollState();+setInterval(pollState, 500);+</script>+</body>+</html>
src/cli.rsadded+231@@ -0,0 +1,231 @@+//! `adom-parts-search search <keyword>` — unified JSON on stdout.+//! `adom-parts-search show <keyword>` — same, but also pushes results into+//! the running Hydrogen webview app so the user sees the 3-column grid.++use crate::vendors;+use crate::{ShowArgs, SearchArgs};++pub fn run_search(args: SearchArgs) -> Result<(), String> {+ let agg = vendors::search_all(+ &args.keyword,+ args.limit,+ args.in_stock_only,+ args.mouser_api.as_deref(),+ args.digikey_api.as_deref(),+ args.jlcpcb_api.as_deref(),+ );+ // OK: line to stderr so stdout stays pure JSON for jq.+ let ok_line = format!(+ "Mouser: {} hit(s) | DigiKey: {} hit(s) | JLCPCB: {} hit(s) | recommendation: {}",+ agg.mouser.hits.len(),+ agg.digikey.hits.len(),+ agg.jlcpcb.hits.len(),+ agg.recommendation.as_ref()+ .map(|r| format!("{} {}", r.winner.display(), r.mpn))+ .unwrap_or_else(|| "none".into()),+ );+ if crate::stderr_is_tty() {+ eprintln!("{}{}OK:{} {}", crate::GREEN, crate::BOLD, crate::RESET, ok_line);+ } else {+ eprintln!("OK: {}", ok_line);+ }+ println!("{}", serde_json::to_string_pretty(&agg).unwrap_or_default());+ Ok(())+}++/// `show` — run the search *through* a running app server, so the same+/// results the AI prints to stdout are the ones the user sees in the+/// webview tab. Auto-starts the app in the background if it isn't up.+pub fn run_show(args: ShowArgs) -> Result<(), String> {+ let base = format!("http://127.0.0.1:{}", args.port);++ // 1. Ensure the app server is up. If not, spawn it detached and wait.+ if !ping(&base) {+ crate::note!("app server not running on :{} — starting it...", args.port);+ spawn_app_detached(args.port, &args.tab_name)?;+ wait_until_up(&base, std::time::Duration::from_secs(10))?;+ crate::ok(format!("app server up on :{}", args.port));+ } else {+ crate::note!("app server already up on :{}", args.port);+ }++ // 2. Ensure the Hydrogen tab exists (if the app was already up without+ // a tab we still want the user to see results).+ ensure_tab(&args.tab_name, args.port);++ // 3. POST the search through the app — server updates its state, UI+ // poller renders the 3 columns, and we get the full JSON back to+ // print to stdout.+ let body = serde_json::json!({+ "keyword": args.keyword,+ "limit": args.limit,+ "inStockOnly": args.in_stock_only,+ });+ let resp = post_json(&format!("{}/search", base), body)+ .map_err(|e| format!("POST /search failed: {}", e))?;++ // 4. Human-readable summary to stdout (AI-friendly).+ render_summary_to_stdout(&args.keyword, &resp);++ // 5. Full JSON so downstream jq / AI has structured access to the data.+ println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default());++ Ok(())+}++fn ping(base: &str) -> bool {+ let agent = ureq::AgentBuilder::new()+ .timeout(std::time::Duration::from_millis(400))+ .build();+ agent.get(&format!("{}/state", base)).call().is_ok()+}++fn wait_until_up(base: &str, total: std::time::Duration) -> Result<(), String> {+ let start = std::time::Instant::now();+ while start.elapsed() < total {+ if ping(base) { return Ok(()); }+ std::thread::sleep(std::time::Duration::from_millis(150));+ }+ Err(format!("app server on {} did not come up within {:?}", base, total))+}++fn spawn_app_detached(port: u16, tab_name: &str) -> Result<(), String> {+ use std::process::{Command, Stdio};+ let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;+ Command::new(exe)+ .args([+ "app",+ "--port", &port.to_string(),+ "--tab-name", tab_name,+ "--no-tab",+ ])+ .stdin(Stdio::null())+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .spawn()+ .map(|_| ())+ .map_err(|e| format!("spawn: {}", e))+}++fn ensure_tab(tab_name: &str, port: u16) {+ use std::process::Command;+ // `find-tab` takes the name as a positional arg. Exit-code isn't+ // a reliable signal (the CLI returns 0 in both found and not-found+ // cases), so parse stdout for an object with `panelId`.+ let already = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "find-tab", tab_name])+ .output()+ .ok()+ .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())+ .map(|v| v.get("panelId").is_some())+ .unwrap_or(false);+ if already {+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "active-tab", "--name", tab_name])+ .output();+ return;+ }+ let url = crate::app::tab::proxy_url(port);+ let icon = include_str!("../docs/icon.svg");+ if let Err(e) = crate::app::tab::add_webview_tab(tab_name, &url, icon) {+ crate::warn(format!("couldn't open tab: {}. Manually visit: {}", e, url));+ }+}++fn post_json(url: &str, body: serde_json::Value) -> Result<serde_json::Value, String> {+ let agent = ureq::AgentBuilder::new()+ .timeout(std::time::Duration::from_secs(60))+ .build();+ match agent.post(url).set("Content-Type", "application/json").send_json(body) {+ Ok(r) => r.into_json().map_err(|e| format!("decode: {}", e)),+ Err(ureq::Error::Status(code, r)) => {+ let body: serde_json::Value = r.into_json().unwrap_or(serde_json::json!({}));+ Err(format!("HTTP {}: {}", code, body))+ }+ Err(e) => Err(e.to_string()),+ }+}++fn render_summary_to_stdout(query: &str, resp: &serde_json::Value) {+ use crate::{BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, stdout_is_tty};++ let color = |code: &str, s: &str| -> String {+ if stdout_is_tty() { format!("{}{}{}", code, s, RESET) } else { s.to_string() }+ };+ println!();+ println!("{} keyword: \"{}\"", color(BOLD, "adom-parts-search"), query);++ for vendor in ["mouser", "digikey", "jlcpcb"] {+ let v = &resp[vendor];+ let color_code = match vendor {+ "mouser" => "\x1b[38;5;39m", // Mouser blue+ "digikey" => "\x1b[38;5;203m", // DigiKey red+ _ => "\x1b[38;5;107m", // JLCPCB green+ };+ let header = color(color_code, &format!("{:<8}", vendor.to_uppercase()));+ let ok_flag = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);+ let hits: Vec<&serde_json::Value> = v.get("hits").and_then(|h| h.as_array()).map(|a| a.iter().collect()).unwrap_or_default();+ let total = v.get("total").and_then(|n| n.as_u64()).unwrap_or(0);+ let elapsed = v.get("elapsed_ms").and_then(|n| n.as_u64()).unwrap_or(0);+ let status = if ok_flag { color(GREEN, "ok") } else {+ let e = v.get("error").and_then(|e| e.as_str()).unwrap_or("");+ color(RED, &format!("err: {}", truncate(e, 60)))+ };+ println!();+ println!("{} total={:>3} hits={:>2} {}ms [{}]",+ header, total, hits.len(), elapsed, status);+ for h in hits.iter().take(5) {+ let mpn = h.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");+ let manuf = h.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("");+ let price1 = h.get("price_1").and_then(|v| v.as_str()).unwrap_or("—");+ let stock = h.get("stock").and_then(|v| v.as_u64()).unwrap_or(0);+ let lead = h.get("lead_time").and_then(|v| v.as_str()).unwrap_or("");+ println!(" {} {} {} stock {} {}",+ color(BOLD, &format!("{:<28}", truncate(mpn, 28))),+ color(DIM, &format!("{:<16}", truncate(manuf, 16))),+ color(YELLOW, &format!("{:>9}", price1)),+ format_stock(stock),+ color(DIM, lead));+ }+ }++ // Recommendation OR empty-state explainer+ if let Some(rec) = resp.get("recommendation").filter(|v| !v.is_null()) {+ let winner = rec.get("winner").and_then(|v| v.as_str()).unwrap_or("—");+ let mpn = rec.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");+ let price = rec.get("unit_price").and_then(|v| v.as_str()).unwrap_or("—");+ let stock = rec.get("stock").and_then(|v| v.as_u64()).unwrap_or(0);+ let reason = rec.get("reason").and_then(|v| v.as_str()).unwrap_or("");+ println!();+ println!("{} {} {} {} stock {}",+ color(BOLD, "RECOMMENDATION →"),+ color(CYAN, winner),+ mpn,+ color(YELLOW, price),+ stock);+ println!(" {}", color(DIM, reason));+ } else if let Some(es) = resp.get("empty_state").filter(|v| !v.is_null()) {+ let title = es.get("title").and_then(|v| v.as_str()).unwrap_or("No results.");+ let expl = es.get("explanation").and_then(|v| v.as_str()).unwrap_or("");+ let hint = es.get("hint").and_then(|v| v.as_str()).unwrap_or("");+ println!();+ println!("{} {}", color(BOLD, "NO MATCH —"), color(YELLOW, title));+ if !expl.is_empty() { println!(" {}", color(DIM, expl)); }+ if !hint.is_empty() { println!(" {} {}", color(CYAN, "→"), hint); }+ }+ println!();+}++fn truncate(s: &str, n: usize) -> String {+ if s.chars().count() <= n { s.to_string() } else {+ let mut out: String = s.chars().take(n.saturating_sub(1)).collect();+ out.push('…'); out+ }+}++fn format_stock(n: u64) -> String {+ if n == 0 { "0".to_string() }+ else if n < 10 { format!("{}", n) }+ else if n < 1000 { format!("{}", n) }+ else { format!("{}k", n / 1000) }+}
src/install.rsadded+95@@ -0,0 +1,95 @@+//! `adom-parts-search install` — deploy SKILL.md + BUILD-SKILL.md + bash completions.+//!+//! Per adom-cli-design: skills are NOT embedded in the binary — they+//! live as independent assets on the wiki and are fetched at install+//! time. Prose changes often; a binary rebuild + release shouldn't be+//! required for a typo fix. Only fall back to the bundled copy when the+//! wiki is unreachable (network outage) so fresh installs still work+//! offline or during migrations.++use crate::{ok, warn};+use clap::CommandFactory;+use clap_complete::Shell;+use std::fs;+use std::io::Write;+use std::path::PathBuf;+use std::time::Duration;++// SAFETY NET only — do not rely on these for day-to-day release+// propagation. The wiki-hosted copy at+// https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/SKILL.md+// is authoritative.+const FALLBACK_SKILL_MD: &str = include_str!("../SKILL.md");+const FALLBACK_BUILD_SKILL_MD: &str = include_str!("../BUILD-SKILL.md");++const WIKI_SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/SKILL.md";+const WIKI_BUILD_SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-parts-search/BUILD-SKILL.md";++fn fetch_or_fallback(url: &str, fallback: &'static str, label: &str) -> String {+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();+ match agent.get(url).call() {+ Ok(r) => match r.into_string() {+ Ok(body) if body.len() >= 200 => body,+ _ => {+ warn(format!("wiki returned short/unreadable {label} — using bundled fallback"));+ fallback.to_string()+ }+ },+ Err(_) => {+ warn(format!("wiki unreachable, installing bundled {label} (may be stale — re-run `adom-parts-search install` once network is up)"));+ fallback.to_string()+ }+ }+}++pub fn install() -> Result<(), String> {+ let home = std::env::var("HOME").map_err(|_| "HOME env var not set".to_string())?;+ let skills_root = PathBuf::from(&home).join(".claude").join("skills");++ // 1a. User-facing skill (fetched from wiki; bundled copy is fallback)+ let skill_md = fetch_or_fallback(WIKI_SKILL_URL, FALLBACK_SKILL_MD, "SKILL.md");+ let skill_dir = skills_root.join("adom-parts-search");+ fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {}", skill_dir.display(), e))?;+ let skill_path = skill_dir.join("SKILL.md");+ fs::write(&skill_path, &skill_md).map_err(|e| format!("write {}: {}", skill_path.display(), e))?;+ ok(format!("installed skill → {} ({} bytes)", skill_path.display(), skill_md.len()));++ // 1b. Build skill (per-app release workflow)+ let build_skill_md = fetch_or_fallback(WIKI_BUILD_SKILL_URL, FALLBACK_BUILD_SKILL_MD, "BUILD-SKILL.md");+ let build_skill_dir = skills_root.join("adom-parts-search-build");+ fs::create_dir_all(&build_skill_dir)+ .map_err(|e| format!("create {}: {}", build_skill_dir.display(), e))?;+ let build_skill_path = build_skill_dir.join("SKILL.md");+ fs::write(&build_skill_path, &build_skill_md)+ .map_err(|e| format!("write {}: {}", build_skill_path.display(), e))?;+ ok(format!("installed build skill → {} ({} bytes)", build_skill_path.display(), build_skill_md.len()));++ // 2. Bash completions+ let comp_dir = PathBuf::from(&home)+ .join(".local")+ .join("share")+ .join("bash-completion")+ .join("completions");+ fs::create_dir_all(&comp_dir).map_err(|e| format!("create {}: {}", comp_dir.display(), e))?;+ let comp_path = comp_dir.join("adom-parts-search");+ let mut cmd = crate::Cli::command();+ let mut buf: Vec<u8> = Vec::new();+ clap_complete::generate(Shell::Bash, &mut cmd, "adom-parts-search", &mut buf);+ fs::write(&comp_path, &buf).map_err(|e| format!("write {}: {}", comp_path.display(), e))?;+ ok(format!("installed completions → {}", comp_path.display()));++ // 3. Append eval to .bashrc once+ let bashrc = PathBuf::from(&home).join(".bashrc");+ let current = fs::read_to_string(&bashrc).unwrap_or_default();+ if !current.contains("# adom-parts-search completions") {+ match fs::OpenOptions::new().append(true).open(&bashrc) {+ Ok(mut f) => {+ let _ = f.write_all(b"\n# adom-parts-search completions\neval \"$(adom-parts-search completions bash 2>/dev/null)\"\n");+ ok("appended completion source line to ~/.bashrc (open a new shell for it to take effect)");+ }+ Err(e) => warn(format!(".bashrc: {}", e)),+ }+ }++ Ok(())+}
src/main.rsadded+179@@ -0,0 +1,179 @@+//! adom-parts-search: unified parts search across Mouser + DigiKey + JLCPCB.+//!+//! The three vendor CLIs (adom-mouser / adom-digikey / adom-jlcpcb) remain+//! the atomic primitives. This app's job is coordination: fire 3 parallel+//! queries, normalize into a unified schema, render a side-by-side grid+//! with product photos (which you can't see in a terminal), and apply the+//! Mouser-preferred routing policy from the `adom-parts-search` skill.+//!+//! Client-only — no service container. Talks to the three existing+//! vendor backends over HTTPS.++mod app;+mod cli;+mod install;+mod vendors;++use clap::{CommandFactory, Parser, Subcommand};+use std::io::IsTerminal;+use std::process::ExitCode;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+pub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+pub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }+pub fn strip_ansi(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars().peekable();+ while let Some(c) = chars.next() {+ if c == '\x1b' && chars.peek() == Some(&'[') {+ chars.next();+ for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }+ continue;+ }+ out.push(c);+ }+ out+}+pub fn ok(m: impl std::fmt::Display) {+ if stdout_is_tty() { println!("{GREEN}{BOLD}OK:{RESET} {m}"); } else { println!("OK: {}", strip_ansi(&m.to_string())); }+}+pub fn err(m: impl std::fmt::Display) {+ if stderr_is_tty() { eprintln!("{RED}{BOLD}ERROR:{RESET} {m}"); } else { eprintln!("ERROR: {}", strip_ansi(&m.to_string())); }+}+pub fn warn(m: impl std::fmt::Display) {+ if stderr_is_tty() { eprintln!("{YELLOW}{BOLD}WARN:{RESET} {m}"); } else { eprintln!("WARN: {}", strip_ansi(&m.to_string())); }+}+pub fn hint(m: impl std::fmt::Display) {+ if stderr_is_tty() { eprintln!("{DIM}Hint:{RESET} {m}"); } else { eprintln!("Hint: {}", strip_ansi(&m.to_string())); }+}+#[macro_export]+macro_rules! note {+ ($($arg:tt)*) => {{+ let __s = format!($($arg)*);+ if $crate::stdout_is_tty() { println!("{}", __s); } else { println!("{}", $crate::strip_ansi(&__s)); }+ }};+}++#[derive(Parser)]+#[command(name = "adom-parts-search", version, about = "Unified parts search across Mouser/DigiKey/JLCPCB with Mouser-preferred routing (Fort Worth 40-min drone delivery)")]+pub struct Cli {+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Search all three vendors in parallel; print unified JSON to stdout.+ Search(SearchArgs),++ /// Open the Hydrogen webview — 3-column grid with product photos.+ App(AppArgs),++ /// DEPRECATED — this feature moved to the `adom-chipfit` app.+ /// See `adom-parts-search align --help` for the one-liner.+ #[command(hide = false)]+ Align(AlignArgs),++ /// Run a search AND push the results into the running webview app so+ /// the user sees the 3-column grid. Prints the full vendor-by-vendor+ /// result summary to stdout so the AI (and user) both see the same+ /// data. Auto-starts the app server + opens the tab if not already up.+ Show(ShowArgs),++ /// Install SKILL.md + bash completions.+ Install,++ /// Print shell completion script.+ Completions { shell: clap_complete::Shell },+}++#[derive(clap::Args)]+pub struct SearchArgs {+ pub keyword: String,+ #[arg(long, default_value_t = 5)] pub limit: u32,+ #[arg(long)] pub in_stock_only: bool,+ #[arg(long, env = "MOUSER_API")] pub mouser_api: Option<String>,+ #[arg(long, env = "DIGIKEY_API")] pub digikey_api: Option<String>,+ #[arg(long, env = "JLCPCB_API")] pub jlcpcb_api: Option<String>,+}++#[derive(clap::Args)]+pub struct ShowArgs {+ pub keyword: String,+ #[arg(long, default_value_t = 5)] pub limit: u32,+ #[arg(long)] pub in_stock_only: bool,+ #[arg(long, default_value_t = 8893)] pub port: u16,+ #[arg(long, default_value = "Parts Search")] pub tab_name: String,+}++#[derive(clap::Args)]+pub struct AlignArgs {+ /// Path to a `.kicad_mod` footprint file.+ #[arg(long)]+ pub footprint: std::path::PathBuf,+ /// Path to a `.glb` model. If you have a STEP, convert first via+ /// `adom-molecule create --step file.step` (or export a .glb from+ /// Fusion / KiCad / your existing pipeline).+ #[arg(long)]+ pub glb: std::path::PathBuf,+ /// HTTP port for the align server. Kept distinct from `app` (8893).+ #[arg(long, default_value_t = 8894)]+ pub port: u16,+ /// Tab name in the Hydrogen workspace.+ #[arg(long, default_value = "Part Align")]+ pub tab_name: String,+ /// Don't open the Hydrogen tab (just run the server — useful for+ /// capturing via pup or curling endpoints directly).+ #[arg(long)]+ pub no_tab: bool,+}++#[derive(clap::Args)]+pub struct AppArgs {+ #[arg(long, default_value_t = 8893)] pub port: u16,+ #[arg(long, default_value = "Parts Search")] pub tab_name: String,+ #[arg(long)] pub no_tab: bool,+ #[arg(long, env = "MOUSER_API")] pub mouser_api: Option<String>,+ #[arg(long, env = "DIGIKEY_API")] pub digikey_api: Option<String>,+ #[arg(long, env = "JLCPCB_API")] pub jlcpcb_api: Option<String>,+ #[arg(long, env = "ADOM_PARTS_SEARCH_DEV")] pub dev: bool,+}++fn main() -> ExitCode {+ let args = Cli::parse();+ let result = match args.cmd {+ Cmd::Search(a) => cli::run_search(a),+ Cmd::Show(a) => cli::run_show(a),+ Cmd::App(a) => app::run(a),+ Cmd::Align(_) => {+ warn("`adom-parts-search align` has moved to its own app: `adom-chipfit`.");+ note!("");+ note!(" Install it (one-liner, run once per container):");+ note!(" {CYAN}curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-chipfit/adom-chipfit \\");+ note!(" -o /tmp/adom-chipfit && chmod +x /tmp/adom-chipfit \\");+ note!(" && sudo install -m 0755 /tmp/adom-chipfit /usr/local/bin/adom-chipfit \\");+ note!(" && adom-chipfit install{RESET}");+ note!("");+ note!(" Then run the same validation via:");+ note!(" {CYAN}adom-chipfit check --footprint <fp.kicad_mod> --glb <chip.glb>{RESET}");+ Err("align has been removed from adom-parts-search — see message above".into())+ }+ Cmd::Install => install::install(),+ Cmd::Completions { shell } => {+ let mut cmd = Cli::command();+ clap_complete::generate(shell, &mut cmd, "adom-parts-search", &mut std::io::stdout());+ Ok(())+ }+ };+ match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { err(&e); ExitCode::FAILURE } }+}
src/vendors.rsadded+568@@ -0,0 +1,568 @@+//! Parallel HTTP aggregator across the three Adom parts services+//! (adom-mouser, adom-digikey, adom-jlcpcb). This module has no API+//! credentials of its own — each vendor's backend already holds them.+//!+//! Output shape: a unified Vec<VendorHit> with the minimum fields the+//! webview needs to render a comparison grid (MPN, image URL, price,+//! stock, link). Normalization across the three vendors' native schemas+//! happens here.++use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::sync::mpsc;+use std::thread;+use std::time::Duration;++pub const DEFAULT_MOUSER: &str = "https://mouser-xr8t4f5d01bt.adom.cloud";+pub const DEFAULT_DIGIKEY: &str = "https://digikey-gjuu8l5t69uh.adom.cloud";+pub const DEFAULT_JLCPCB: &str = "https://jlcpcb-wela51osctvp.adom.cloud";++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]+pub enum Vendor { Mouser, DigiKey, JLCPCB }++impl Vendor {+ pub fn as_str(&self) -> &'static str {+ match self { Vendor::Mouser => "mouser", Vendor::DigiKey => "digikey", Vendor::JLCPCB => "jlcpcb" }+ }+ pub fn display(&self) -> &'static str {+ match self { Vendor::Mouser => "Mouser", Vendor::DigiKey => "DigiKey", Vendor::JLCPCB => "JLCPCB" }+ }+}++// VendorHit.cad_3d_* is populated opportunistically per-vendor:+// DigiKey — from MediaLinks[3D Model] in its v4 response+// Mouser — not exposed in their API (field stays None)+// JLCPCB — not exposed by CDFER scrape (stays None), BUT the+// aggregator below does a cross-vendor fallback: if a JLCPCB hit+// shares an MPN with a DigiKey hit that has a cad_3d_url, we+// copy it onto the JLCPCB card so the user gets the link+// regardless of which column they clicked.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct VendorHit {+ pub vendor: Vendor,+ pub vendor_pn: String, // mouser_pn, digikey_pn, or LCSC part number+ pub mpn: String, // manufacturer PN — the cross-vendor match key+ pub manufacturer: String,+ pub description: String,+ pub stock: u64,+ pub unit_price: Option<String>,+ pub price_100: Option<String>,+ pub lead_time: String,+ pub image_url: Option<String>,+ pub datasheet_url: Option<String>,+ pub product_url: Option<String>,+ pub flags: Vec<String>, // "RoHS", "basic", "preferred", etc.+ /// URL to a STEP/IGES/GLB/ZIP CAD model, when the source vendor+ /// exposes one. Click-through usually redirects through SamacSys /+ /// SnapEDA / Ultra Librarian auth.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub cad_3d_url: Option<String>,+ /// Best-guess CAD file format ("step", "iges", "stl", "zip").+ #[serde(skip_serializing_if = "Option::is_none")]+ pub cad_3d_format: Option<String>,+ /// Origin of the CAD URL — "digikey" (native), "digikey-xref"+ /// (cross-vendor fallback onto JLCPCB/Mouser card), etc.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub cad_3d_source: Option<String>,+}++#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct AggregatedResults {+ pub query: String,+ pub mouser: VendorBlock,+ pub digikey: VendorBlock,+ pub jlcpcb: VendorBlock,+ pub recommendation: Option<Recommendation>,+ /// Populated when no vendor produced a usable hit. Tells the user+ /// why (no matches vs. all unreachable) and suggests a next step.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub empty_state: Option<EmptyState>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct EmptyState {+ pub title: String,+ pub explanation: String,+ pub hint: String,+}++#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct VendorBlock {+ pub ok: bool,+ /// Short, human-friendly one-liner. Never contains API keys or URLs.+ pub error: Option<String>,+ /// Longer explanation of what happened + what the user can expect.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub error_explanation: Option<String>,+ /// Actionable hint: what to try, when to retry, who to ping.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub error_hint: Option<String>,+ pub total: u64,+ pub hits: Vec<VendorHit>,+ pub backend_url: String,+ pub elapsed_ms: u64,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Recommendation {+ pub winner: Vendor,+ pub reason: String,+ pub mpn: String,+ pub unit_price: Option<String>,+ pub stock: u64,+}++/// Keep hits that contain the primary query token (>=3 char alphanumeric+/// run) in either MPN or description. Vendor backends that correctly+/// pass our MPN-or-keyword into FTS will be untouched; backends that+/// default-respond with "popular parts" on no-match (JLCPCB today) will+/// be filtered down to zero hits.+fn filter_relevant(hits: &[VendorHit], query: &str) -> Vec<VendorHit> {+ let q_lower = query.to_lowercase();+ // Primary token: longest alphanumeric run in the query.+ let primary: String = q_lower+ .split(|c: char| !c.is_ascii_alphanumeric())+ .filter(|s| s.len() >= 3)+ .max_by_key(|s| s.len())+ .unwrap_or("")+ .to_string();+ if primary.is_empty() {+ return hits.to_vec();+ }+ hits.iter().filter(|h| {+ let mpn = h.mpn.to_lowercase();+ let desc = h.description.to_lowercase();+ mpn.contains(&primary) || desc.contains(&primary)+ }).cloned().collect()+}++/// Translate a raw backend error into a short title + longer explanation+/// + actionable hint, written for a non-expert Adom user. Redacts API+/// keys, URLs, and stack traces before they reach stdout or the UI.+fn classify_error(vendor: Vendor, raw: &str) -> (String, String, String) {+ let vendor_name = vendor.display();+ let lower = raw.to_lowercase();++ // Known: Mouser free-tier daily quota exhausted (HTTP 403 with+ // "TooManyRequests" / "Maximum calls per day exceeded").+ if lower.contains("maximum calls per day") || lower.contains("toomanyrequests") {+ return (+ format!("{vendor_name}'s daily search quota is used up."),+ format!(+ "Our {vendor_name} API key is on a free-tier plan that allows a fixed number of \+ searches per day, and we've already used them all. The counter resets at \+ midnight Central Time."+ ),+ "DigiKey and JLCPCB results below are still live. If you need Mouser pricing \+ before midnight CT, rotate the key or bump the plan — ask an Adom admin.".into(),+ );+ }++ // Auth failures (401/403 without the quota marker).+ if lower.contains("status code 401") || lower.contains("status code 403")+ || lower.contains("unauthorized") || lower.contains("forbidden") {+ return (+ format!("{vendor_name} rejected our API key."),+ format!(+ "The {vendor_name} backend returned a permission error for the stored API \+ key. The key may have been rotated, revoked, or is missing from the \+ service container."+ ),+ format!("Ask an Adom admin to refresh the {vendor_name} key on the service container."),+ );+ }++ // Network/container-up but upstream unreachable / timeout.+ if lower.contains("timed out") || lower.contains("timeout")+ || lower.contains("connection refused") || lower.contains("connection reset") {+ return (+ format!("{vendor_name} backend is slow or unreachable right now."),+ format!(+ "We couldn't reach the {vendor_name} search service in time. This usually \+ means the service container is restarting or the upstream vendor API is \+ temporarily slow."+ ),+ "Try the search again in a minute. Other vendors' results are unaffected.".into(),+ );+ }++ // Backend-level errors (5xx, internal errors, panics).+ if lower.contains("status code 5") || lower.contains("internal server error")+ || lower.contains("500") || lower.contains("502") || lower.contains("503") || lower.contains("504") {+ return (+ format!("{vendor_name} backend hit an internal error."),+ format!(+ "The {vendor_name} search service replied with an unexpected server error. \+ This is usually transient."+ ),+ "Try the search again in a minute. If this keeps happening, ask an Adom admin \+ to check the service container's logs.".into(),+ );+ }++ // Rate limiting that isn't quota-per-day (429 etc).+ if lower.contains("status code 429") || lower.contains("rate limit") || lower.contains("too many requests") {+ return (+ format!("{vendor_name} temporarily rate-limited us."),+ format!(+ "The {vendor_name} API asked us to slow down. This is short-term throttling, \+ not a daily cap."+ ),+ "Wait 10-30 seconds and try again. If it persists, stagger your searches.".into(),+ );+ }++ // Fallback: generic, with the vendor named but never the raw URL/key.+ (+ format!("{vendor_name} search failed."),+ format!(+ "The {vendor_name} backend returned an error we don't have a canned explanation \+ for. DigiKey and JLCPCB results below are still valid."+ ),+ "Try the search again. If it keeps failing, show this page to an Adom admin.".into(),+ )+}++/// Remove anything that looks like a secret or internal URL from a raw+/// error message before it appears in logs. Called on the STDERR path+/// only; UI errors already come from `classify_error` and never include+/// the raw string.+#[allow(dead_code)]+fn redact(raw: &str) -> String {+ let mut s = raw.to_string();+ // Strip `apiKey=xxx...` query parameters+ if let Some(start) = s.find("apiKey=") {+ let end = s[start..].find(|c: char| c == '&' || c == ' ' || c == '"').map(|i| start + i).unwrap_or(s.len());+ s.replace_range(start..end, "apiKey=***redacted***");+ }+ s+}++fn post_action(url: &str, body: Value) -> Result<Value, String> {+ let url = if url.ends_with('/') { url.to_string() } else { format!("{}/", url) };+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build();+ let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {+ Ok(r) => r,+ Err(ureq::Error::Status(_, r)) => r,+ Err(e) => return Err(format!("{}", e)),+ };+ res.into_json().map_err(|e| format!("JSON: {}", e))+}++fn price_at_qty(tiers: Option<&Vec<Value>>, qty: u32) -> Option<String> {+ let tiers = tiers?;+ // Find the tier with the largest qty ≤ requested+ let mut best: Option<(u32, String)> = None;+ for t in tiers {+ let tqty = t.get("qty").and_then(|v| v.as_u64()).unwrap_or(0) as u32;+ let tprice = t.get("price").and_then(|v| v.as_str()).unwrap_or("");+ if tqty <= qty && !tprice.is_empty() {+ let keep = best.as_ref().map(|(q, _)| tqty >= *q).unwrap_or(true);+ if keep {+ best = Some((tqty, tprice.to_string()));+ }+ }+ }+ best.map(|(_, p)| p)+}++fn normalize_mouser(c: &Value) -> VendorHit {+ let tiers = c.get("price_tiers").and_then(|v| v.as_array());+ VendorHit {+ vendor: Vendor::Mouser,+ vendor_pn: c.get("mouser_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),+ unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),+ price_100: price_at_qty(tiers, 100),+ lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),+ datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),+ product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),+ flags: {+ let mut f = vec![];+ if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }+ if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }+ f+ },+ cad_3d_url: None, // Mouser API doesn't expose CAD links+ cad_3d_format: None,+ cad_3d_source: None,+ }+}++fn normalize_digikey(c: &Value) -> VendorHit {+ let tiers = c.get("price_tiers").and_then(|v| v.as_array());+ let cad_url = c.get("cad_3d_url").and_then(|v| v.as_str()).map(String::from);+ let cad_fmt = c.get("cad_3d_format").and_then(|v| v.as_str()).map(String::from);+ let has_cad = cad_url.is_some();+ VendorHit {+ vendor: Vendor::DigiKey,+ vendor_pn: c.get("digikey_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),+ unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),+ price_100: price_at_qty(tiers, 100),+ lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),+ datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),+ product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),+ flags: {+ let mut f = vec![];+ if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }+ if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }+ f+ },+ cad_3d_url: cad_url,+ cad_3d_format: cad_fmt,+ cad_3d_source: if has_cad { Some("digikey".into()) } else { None },+ }+}++fn normalize_jlcpcb(c: &Value) -> VendorHit {+ let tiers = c.get("price_tiers").and_then(|v| v.as_array());+ let is_basic = c.get("is_basic").and_then(|v| v.as_bool()).unwrap_or(false);+ let is_preferred = c.get("is_preferred").and_then(|v| v.as_bool()).unwrap_or(false);+ let lcsc = c.get("lcsc").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let images = c.get("images").and_then(|v| v.as_array());+ let image_url = c.get("image_url").and_then(|v| v.as_str()).map(String::from)+ .or_else(|| images.and_then(|a| a.first()).and_then(|v| v.as_str()).map(String::from));+ VendorHit {+ vendor: Vendor::JLCPCB,+ vendor_pn: lcsc.clone(),+ mpn: c.get("mfr").or_else(|| c.get("mpn")).and_then(|v| v.as_str()).unwrap_or("").to_string(),+ manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),+ stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),+ unit_price: tiers.and_then(|t| t.first()).and_then(|t| t.get("price")).and_then(|v| v.as_str()).map(String::from),+ price_100: price_at_qty(tiers, 100),+ lead_time: String::new(),+ image_url,+ datasheet_url: c.get("datasheet").and_then(|v| v.as_str()).map(String::from),+ product_url: c.get("jlcpcb_url").and_then(|v| v.as_str()).map(String::from),+ flags: {+ let mut f = vec![];+ if is_basic { f.push("basic".into()); }+ if is_preferred { f.push("preferred".into()); }+ if !is_basic && !is_preferred { f.push("extended".into()); }+ f+ },+ cad_3d_url: None, // filled in post-aggregation by cross-vendor MPN fallback+ cad_3d_format: None,+ cad_3d_source: None,+ }+}++/// Fire three parallel HTTP POSTs to the vendor backends. Returns a+/// single unified AggregatedResults.+pub fn search_all(+ query: &str,+ limit: u32,+ in_stock_only: bool,+ mouser_api: Option<&str>,+ digikey_api: Option<&str>,+ jlcpcb_api: Option<&str>,+) -> AggregatedResults {+ let m_url = mouser_api.unwrap_or(DEFAULT_MOUSER).to_string();+ let d_url = digikey_api.unwrap_or(DEFAULT_DIGIKEY).to_string();+ let j_url = jlcpcb_api.unwrap_or(DEFAULT_JLCPCB).to_string();+ let q = query.to_string();++ let (tx, rx) = mpsc::channel::<(Vendor, VendorBlock)>();+ let body_common = json!({"keyword": q, "limit": limit, "inStockOnly": in_stock_only});++ // Spawn three threads — one per vendor.+ for (vendor, url) in [(Vendor::Mouser, m_url.clone()), (Vendor::DigiKey, d_url.clone()), (Vendor::JLCPCB, j_url.clone())] {+ let tx = tx.clone();+ let body = {+ let mut b = body_common.clone();+ b["action"] = Value::String("search".into());+ b+ };+ let url_for_block = url.clone();+ let q_for_thread = q.clone();+ thread::spawn(move || {+ let t0 = std::time::Instant::now();+ let result = post_action(&url, body);+ let elapsed = t0.elapsed().as_millis() as u64;+ let mut block = VendorBlock {+ ok: false, error: None, error_explanation: None, error_hint: None,+ total: 0, hits: vec![],+ backend_url: url_for_block, elapsed_ms: elapsed,+ };+ match result {+ Ok(v) => {+ if let Some(e) = v.get("error").and_then(|v| v.as_str()).or(+ v.get("api_error").and_then(|v| v.as_str())+ ) {+ let (t, expl, hint) = classify_error(vendor, e);+ block.error = Some(t);+ block.error_explanation = Some(expl);+ block.error_hint = Some(hint);+ } else {+ block.ok = true;+ block.total = v.get("count").and_then(|n| n.as_u64()).unwrap_or(0);+ let components = v.get("components").and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let raw: Vec<VendorHit> = components.iter().map(|c| match vendor {+ Vendor::Mouser => normalize_mouser(c),+ Vendor::DigiKey => normalize_digikey(c),+ Vendor::JLCPCB => normalize_jlcpcb(c),+ }).collect();+ // Drop hits that don't contain the primary query+ // token in the MPN or description. JLCPCB currently+ // returns its default popular-parts list on no-match,+ // so filter vendor-side. Safe for all vendors; vendor+ // FTS that does match will keep its hits.+ block.hits = filter_relevant(&raw, &q_for_thread);+ // Sort by stock DESC so the most-useful variant is+ // the first card a user sees. Mouser + DigiKey often+ // return a stock=0 "parent" MPN before the stocked+ // reel/cut-tape children; rendering that stock=0+ // card first makes the whole column look empty.+ block.hits.sort_by(|a, b| b.stock.cmp(&a.stock));+ // If everything got filtered as irrelevant, reset+ // `total` so the UI shows "0 matches".+ if block.hits.is_empty() { block.total = 0; }+ }+ }+ Err(e) => {+ let (t, expl, hint) = classify_error(vendor, &e);+ block.error = Some(t);+ block.error_explanation = Some(expl);+ block.error_hint = Some(hint);+ }+ }+ let _ = tx.send((vendor, block));+ });+ }+ drop(tx);++ let mut agg = AggregatedResults { query: query.into(), ..Default::default() };+ for _ in 0..3 {+ match rx.recv() {+ Ok((Vendor::Mouser, b)) => agg.mouser = b,+ Ok((Vendor::DigiKey, b)) => agg.digikey = b,+ Ok((Vendor::JLCPCB, b)) => agg.jlcpcb = b,+ Err(_) => break,+ }+ }+ // Cross-vendor CAD fallback: DigiKey is the only backend that+ // exposes CAD links today. For any Mouser or JLCPCB hit whose MPN+ // matches a DigiKey hit with a cad_3d_url, copy that URL onto+ // the sibling card so clicking "3D" from ANY column works.+ // Tagged cad_3d_source = "digikey-xref" so the UI + downstream+ // can tell xref-sourced URLs from native ones.+ let dk_cad: std::collections::HashMap<String, (String, Option<String>)> = agg.digikey.hits.iter()+ .filter(|h| h.cad_3d_url.is_some())+ .map(|h| (h.mpn.to_lowercase(), (h.cad_3d_url.clone().unwrap(), h.cad_3d_format.clone())))+ .collect();+ if !dk_cad.is_empty() {+ for h in agg.mouser.hits.iter_mut().chain(agg.jlcpcb.hits.iter_mut()) {+ if h.cad_3d_url.is_some() { continue; }+ if let Some((url, fmt)) = dk_cad.get(&h.mpn.to_lowercase()) {+ h.cad_3d_url = Some(url.clone());+ h.cad_3d_format = fmt.clone();+ h.cad_3d_source = Some("digikey-xref".into());+ }+ }+ }++ agg.recommendation = pick_winner(&agg);+ if agg.recommendation.is_none() {+ agg.empty_state = Some(build_empty_state(&agg));+ }+ agg+}++fn build_empty_state(agg: &AggregatedResults) -> EmptyState {+ let q = &agg.query;+ let m_ok = agg.mouser.ok; let d_ok = agg.digikey.ok; let j_ok = agg.jlcpcb.ok;+ let m_hits = agg.mouser.hits.len(); let d_hits = agg.digikey.hits.len(); let j_hits = agg.jlcpcb.hits.len();++ // All three errored — service degradation, not a search-miss.+ if !m_ok && !d_ok && !j_ok {+ return EmptyState {+ title: "All three vendor backends are currently unreachable.".into(),+ explanation: format!(+ "Mouser, DigiKey, and JLCPCB all returned errors for \"{q}\". This is a \+ service-level issue, not a bad part number."+ ),+ hint: "Check https://status.adom.cloud or ping an admin. Individual vendor errors \+ are shown in each column.".into(),+ };+ }++ // All three OK but zero hits — the part name didn't match anything.+ if m_ok && d_ok && j_ok && m_hits == 0 && d_hits == 0 && j_hits == 0 {+ return EmptyState {+ title: format!("No matches for \"{q}\" on any vendor."),+ explanation: "All three distributors returned zero hits. Vendor APIs match on MPN \+ and description substrings, not on natural-language intent — so a \+ typo, a too-broad phrase, or an out-of-family name will produce this \+ empty result.".into(),+ hint: "Check the MPN spelling, try a cleaner keyword (e.g. LM555 instead of \+ \"555 timer chip\"), or pick a known family member. Common substitutes \+ are documented in the adom-parts-search skill's family table.".into(),+ };+ }++ // Mixed: some vendors OK with 0 hits, others errored — partial visibility.+ let live_vendors: Vec<&str> = [+ (m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),+ ].iter().filter(|(ok, _)| *ok).map(|(_, n)| *n).collect();+ let down_vendors: Vec<&str> = [+ (m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),+ ].iter().filter(|(ok, _)| !*ok).map(|(_, n)| *n).collect();+ EmptyState {+ title: format!("No in-stock matches for \"{q}\" right now."),+ explanation: format!(+ "{} returned zero hits. {} {} currently erroring (see the column \+ card for details), so coverage is partial.",+ live_vendors.join(" + "),+ down_vendors.join(" + "),+ if down_vendors.len() == 1 { "is" } else { "are" }+ ),+ hint: "Try a canonical family MPN (LM555 / NE555 / STM32F103C8T6 / ESP32-S3-WROOM-1U-N4R2 \+ / iCE40HX4K-TQ144) or retry in a minute once the erroring backends recover.".into(),+ }+}++/// Apply the Mouser-preferred policy from adom-parts-search SKILL:+/// near-zero shipping + 40-min drone means Mouser wins unless it's+/// out of stock / high-lead / DigiKey is dramatically cheaper.+///+/// We scan ALL hits per vendor, not just the first one — it's common+/// for Mouser to return a handful of stock=0 entries before the real+/// in-stock variant. Pick the FIRST in-stock hit per vendor, then+/// apply the vendor-preference policy on top.+fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> {+ let long_lead = |lt: &str| lt.contains("280");+ let first_in_stock = |hits: &[VendorHit]| {+ hits.iter().find(|h| h.stock > 0 && !long_lead(&h.lead_time)).cloned()+ };+ let m = first_in_stock(&agg.mouser.hits);+ let d = first_in_stock(&agg.digikey.hits);+ if let Some(m) = &m {+ return Some(Recommendation {+ winner: Vendor::Mouser,+ reason: "Mouser stocked — 40-min drone delivery from Mansfield, near-zero shipping.".into(),+ mpn: m.mpn.clone(),+ unit_price: m.unit_price.clone(),+ stock: m.stock,+ });+ }+ if let Some(d) = &d {+ return Some(Recommendation {+ winner: Vendor::DigiKey,+ reason: "Mouser is out of stock or long-lead; DigiKey has immediate stock.".into(),+ mpn: d.mpn.clone(),+ unit_price: d.unit_price.clone(),+ stock: d.stock,+ });+ }+ None+}