app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
← Commit history
1.3.0: Dock Bar contract (serve --print-url, /api/status LEDs, CORS, say strip), settings modal, taskbar progress, pup vault for logins, adom-bridge + pup_* transport, dashboard overhaul per adom-ui-design, doctor for first run
34 files changed
+4645−2847
.gitignore+3Cargo.lock+1−1Cargo.toml+1−1SKILL.md+1−1bin/adom-chip-fetcherdocs/architecture-flow.svg+173docs/dashboard-api-contract.md+107docs/ui-audit-2026-08-23.md+24src/bom.rs+2−2src/bridge.rs+218src/cli.rs+547−213src/deliver.rs+12−12src/dock.rs+541src/enrich.rs+5−5src/hints.rs+87−70src/import.rs+21−22src/inventory.rs+17−18src/jobs.rs+160src/library.rs+10−5src/main.rs+6−1src/provenance.rs+9−10src/pup.rs+45−61src/serve.rs+345−68src/settings.rs+149src/sources/jst.rs+13−13src/sources/wiki.rs+11−11src/sourcing.rs+10−10src/taskbar.rs+172src/thumbnails.rs+11−12src/ui/index.html+1655−2277src/validate.rs+20−21src/vault.rs+110tool-map.html+13−13tools/hero/compose_hero.py+146.gitignore+3@@ -4,3 +4,6 @@ incoming/ archive/ *.log .DS_Store+__pycache__/+*.pyc+bin/
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-chip-fetcher"-version = "1.2.24"+version = "1.3.0" dependencies = [ "anyhow", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-chip-fetcher"-version = "1.2.24"+version = "1.3.0" edition = "2021" description = "Download manufacturer-grade chip CAD bundles (STEP, KiCAD symbol/footprint, datasheets) into a structured library by driving Chrome via pup."
SKILL.md+1−1@@ -167,7 +167,7 @@ Side-effect docs that matter: When chip-fetcher sources a STEP + footprint bundle (e.g. from SnapMagic), both files are a matched pair. Downstream tools **MUST** use both together -- never pair a chip-fetcher STEP with a tscircuit footprint or vice versa. The `file_provenance` JSON tracks which source each file came from so mismatches can be detected. -**Why:** The SnapMagic footprint has pad positions, mounting holes, and courtyard geometry designed to match the SnapMagic STEP model exactly. A tscircuit-exported footprint for the same component has different pad positions. Using the wrong combination causes the 3D model to be misaligned with the pads -- even though each file is individually correct.+**Why:** The SnapMagic footprint has pad positions, mounting holes, and courtyard geometry designed to match the SnapMagic STEP model exactly. A tscircuit-exported footprint for the same component has different pad positions. Using the wrong combination causes the 3D model to be misaligned with the pads, even though each file is individually correct. **Enforcement:** When `chip-fetcher gate` or `chip-fetcher status` reports a component as complete, the .kicad_mod and .step in `library/<MPN>/` are guaranteed to be from the same source. Any downstream tool (board-building-pipeline, kicad-3d-models) that substitutes a different footprint breaks this guarantee.
bin/adom-chip-fetcher⋯ 1 unchanged line ⋯
docs/architecture-flow.svgadded+173@@ -0,0 +1,173 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1520" height="1660" viewBox="0 0 1520 1660" font-family="Sat,sans-serif">+<defs><style>+@font-face{font-family:'Grot';src:url(data:font/woff2;base64,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) format('woff2');font-weight:700}+@font-face{font-family:'Sat';src:url(data:font/woff2;base64,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) format('woff2');font-weight:400}+@font-face{font-family:'Sat';src:url(data:font/woff2;base64,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) format('woff2');font-weight:500}+</style>+<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0a0e14"/><stop offset="0.5" stop-color="#0d1117"/><stop offset="1" stop-color="#071318"/></linearGradient>+<linearGradient id="acc" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#00e6dc"/><stop offset="0.6" stop-color="#39b8ff"/><stop offset="1" stop-color="#8c6bf7"/></linearGradient>+<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#00b8b1" stop-opacity="0.16"/><stop offset="1" stop-color="#00b8b1" stop-opacity="0"/></radialGradient>+<marker id="ah" markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#3a4658"/></marker></defs>+<rect width="1520" height="1660" fill="url(#bg)"/>+<ellipse cx="1368.0" cy="60" rx="520" ry="360" fill="url(#glow)"/>+<text x="60" y="78" font-family="Grot" font-size="46" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="-1">chip-fetcher</text>+<text x="374" y="78" font-family="Grot" font-size="46" font-weight="700" fill="url(#acc)" letter-spacing="-1">· one prompt → every EDA tool</text>+<text x="62" y="112" font-family="Sat" font-size="19" fill="#aeb8c4" font-weight="400" text-anchor="start" letter-spacing="0">Ask in plain language. chip-fetcher gathers manufacturer-grade CAD, validates it across sources, and ships it to KiCad / Fusion / Altium / OrCAD.</text>+<circle cx="70" cy="185" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="192" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">1</text>+<text x="144" y="181" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">ASK</text>+<text x="144" y="205" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">natural-language intent → a resolved part list</text>+<rect x="120" y="229" width="1350" height="112" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="142" y="247" width="360" height="40" rx="20.0" fill="#10202a" stroke="#1c5b59" stroke-width="1.4"/>+<text x="322.0" y="272.95" font-family="Sat" font-size="17" fill="#bfeae8" font-weight="500" text-anchor="middle" letter-spacing="0">“get me all Adom basic parts”</text>+<rect x="142" y="293" width="360" height="36" rx="18.0" fill="#10202a" stroke="#1c5b59" stroke-width="1.4"/>+<text x="322.0" y="316.25" font-family="Sat" font-size="15" fill="#bfeae8" font-weight="500" text-anchor="middle" letter-spacing="0">“all time-of-flight sensors from ST”</text>+<line x1="516" y1="291" x2="572" y2="291" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<text x="590" y="273" font-family="Sat" font-size="16" fill="#aeb8c4" font-weight="400" text-anchor="start" letter-spacing="0">chip-fetcher resolves it to</text>+<rect x="590" y="285" width="260" height="38" rx="19.0" fill="#0f141c" stroke="#2b3645" stroke-width="1.4"/>+<circle cx="608" cy="304.0" r="5" fill="#8c6bf7"/>+<text x="622" y="309.25" font-family="Sat" font-size="15" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">N part numbers (MPNs)</text>+<text x="880" y="311" font-family="Sat" font-size="14" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">each one walks the source ladder ↓</text>+<line x1="70" y1="349" x2="70" y2="373" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<circle cx="70" cy="399" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="406" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">2</text>+<text x="144" y="395" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">SOURCE · priority ladder</text>+<text x="144" y="419" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">walk top-to-bottom per part; stop at the first that delivers</text>+<rect x="120" y="443" width="1350" height="150" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="140.0" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#00e6dc" stroke-width="1.6"/>+<text x="215.75" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#00e6dc" text-anchor="middle">1</text>+<text x="215.75" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Adom Wiki</text>+<text x="215.75" y="515" font-family="Sat" font-size="10.5" fill="#8b949e" font-weight="400" text-anchor="middle" letter-spacing="0">reuse</text>+<text x="298.5" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="305.5" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#34d17e" stroke-width="1.6"/>+<text x="381.25" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#34d17e" text-anchor="middle">2</text>+<text x="381.25" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Manufacturer</text>+<text x="381.25" y="515" font-family="Sat" font-size="10.5" fill="#8b949e" font-weight="400" text-anchor="middle" letter-spacing="0">vendor.com</text>+<text x="464.0" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="471.0" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#39b8ff" stroke-width="1.6"/>+<text x="546.75" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#39b8ff" text-anchor="middle">3</text>+<text x="546.75" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">SnapMagic</text>+<text x="629.5" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="636.5" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#39b8ff" stroke-width="1.6"/>+<text x="712.25" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#39b8ff" text-anchor="middle">4</text>+<text x="712.25" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Mouser</text>+<text x="795.0" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="802.0" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#39b8ff" stroke-width="1.6"/>+<text x="877.75" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#39b8ff" text-anchor="middle">5</text>+<text x="877.75" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">DigiKey</text>+<text x="960.5" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="967.5" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#39b8ff" stroke-width="1.6"/>+<text x="1043.25" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#39b8ff" text-anchor="middle">6</text>+<text x="1043.25" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Arrow</text>+<text x="1126.0" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="1133.0" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#8c6bf7" stroke-width="1.6"/>+<text x="1208.75" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#8c6bf7" text-anchor="middle">7</text>+<text x="1208.75" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">CSE / UL</text>+<text x="1291.5" y="493" font-family="Sat" font-size="16" fill="#3a4658" text-anchor="middle">›</text>+<rect x="1298.5" y="461" width="151.5" height="58" rx="10" fill="#0f141c" stroke="#f59e0b" stroke-width="1.6"/>+<text x="1374.25" y="483" font-family="Grot" font-size="15" font-weight="700" fill="#f59e0b" text-anchor="middle">8</text>+<text x="1374.25" y="501" font-family="Sat" font-size="12.5" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">LCSC</text>+<text x="1374.25" y="515" font-family="Sat" font-size="10.5" fill="#8b949e" font-weight="400" text-anchor="middle" letter-spacing="0">last resort</text>+<text x="140" y="551" font-family="Sat" font-size="15" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">Pulls per part:</text>+<rect x="270" y="535" width="150" height="30" rx="15.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<text x="345.0" y="554.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="middle" letter-spacing="0">STEP</text>+<rect x="470" y="535" width="150" height="30" rx="15.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<text x="545.0" y="554.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="middle" letter-spacing="0">datasheet PDF</text>+<rect x="670" y="535" width="150" height="30" rx="15.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<text x="745.0" y="554.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="middle" letter-spacing="0">.kicad_sym</text>+<rect x="870" y="535" width="150" height="30" rx="15.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<text x="945.0" y="554.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="middle" letter-spacing="0">.kicad_mod</text>+<rect x="1070" y="535" width="190" height="30" rx="15.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<text x="1165.0" y="554.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="middle" letter-spacing="0">+ Altium / Fusion (when offered)</text>+<line x1="70" y1="601" x2="70" y2="625" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<circle cx="70" cy="651" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="658" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">3</text>+<text x="144" y="647" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">VERSIONS · multiple sources of truth</text>+<text x="144" y="671" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">per part, several candidate SYM + FP. pick any in the dashboard carat</text>+<rect x="120" y="695" width="1350" height="150" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="140.0" y="711" width="315.5" height="58" rx="10" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<rect x="140.0" y="711" width="5" height="58" rx="2" fill="#39b8ff"/>+<text x="156.0" y="735" font-family="Sat" font-size="15" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">Vendor download</text>+<text x="156.0" y="755" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">KiCad / UltraLibrarian bundle</text>+<rect x="471.5" y="711" width="315.5" height="58" rx="10" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<rect x="471.5" y="711" width="5" height="58" rx="2" fill="#00e6dc"/>+<text x="487.5" y="735" font-family="Sat" font-size="15" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">ds2sf</text>+<text x="487.5" y="755" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">datasheet → Adom-grouped sym + fp</text>+<rect x="803.0" y="711" width="315.5" height="58" rx="10" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<rect x="803.0" y="711" width="5" height="58" rx="2" fill="#8c6bf7"/>+<text x="819.0" y="735" font-family="Sat" font-size="15" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">EAGLE / Fusion</text>+<text x="819.0" y="755" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">.lbr geometry</text>+<rect x="1134.5" y="711" width="315.5" height="58" rx="10" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<rect x="1134.5" y="711" width="5" height="58" rx="2" fill="#8c6bf7"/>+<text x="1150.5" y="735" font-family="Sat" font-size="15" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">Altium</text>+<text x="1150.5" y="755" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">.IntLib / .SchLib</text>+<text x="140" y="799" font-family="Sat" font-size="20" fill="#00b8b1" font-weight="700" text-anchor="start" letter-spacing="0">+</text>+<rect x="164" y="781" width="340" height="32" rx="16.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<circle cx="182" cy="797.0" r="5" fill="#00e6dc"/>+<text x="196" y="801.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="start" letter-spacing="0">chip-thumbnailer → 3D icons + white outline</text>+<text x="518" y="801" font-family="Sat" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">(STEP → step2glb)</text>+<rect x="660" y="781" width="310" height="32" rx="16.0" fill="#0d1117" stroke="#2b3645" stroke-width="1.4"/>+<circle cx="678" cy="797.0" r="5" fill="#f59e0b"/>+<text x="692" y="801.55" font-family="Sat" font-size="13" fill="#aeb8c4" font-weight="500" text-anchor="start" letter-spacing="0">datasheet PDF + design-help PDFs</text>+<line x1="70" y1="853" x2="70" y2="877" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<circle cx="70" cy="903" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="910" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">4</text>+<text x="144" y="899" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">VALIDATE · concur</text>+<text x="144" y="923" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">cross-source consensus: do all the versions agree?</text>+<rect x="120" y="947" width="1350" height="176" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="140" y="963" width="1310" height="66" rx="10" fill="#10171f" stroke="#00b8b1" stroke-width="1.6"/>+<text x="158" y="991" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="start" letter-spacing="0">concur</text>+<text x="158" y="1013" font-family="Sat" font-size="14.5" fill="#aeb8c4" font-weight="400" text-anchor="start" letter-spacing="0">compares pin count · pad geometry · pin names · package family across every version</text>+<rect x="1208" y="977" width="100" height="38" rx="19.0" fill="#0f2018" stroke="#34d17e" stroke-width="1.4"/>+<text x="1258.0" y="1001.25" font-family="Sat" font-size="15" fill="#7ee2a8" font-weight="500" text-anchor="middle" letter-spacing="0">GOLDEN</text>+<text x="1314" y="1002" font-family="Sat" font-size="22" fill="#34d17e">✓</text>+<rect x="1338" y="977" width="92" height="38" rx="19.0" fill="#241a12" stroke="#f59e0b" stroke-width="1.4"/>+<text x="1384.0" y="1000.375" font-family="Sat" font-size="12.5" fill="#f3c277" font-weight="500" text-anchor="middle" letter-spacing="0">DIVERGENT</text>+<text x="140" y="1059" font-family="Sat" font-size="14" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">with</text>+<rect x="190.0" y="1039" width="402.6666666666667" height="46" rx="9" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<text x="204.0" y="1061" font-family="Sat" font-size="14" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">chipfit</text>+<text x="204.0" y="1078" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">3D ↔ footprint align</text>+<rect x="608.6666666666667" y="1039" width="402.6666666666667" height="46" rx="9" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<text x="622.6666666666667" y="1061" font-family="Sat" font-size="14" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">chipsmith</text>+<text x="622.6666666666667" y="1078" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">pin detection + sign-off</text>+<rect x="1027.3333333333335" y="1039" width="402.6666666666667" height="46" rx="9" fill="#0f141c" stroke="#2b3645" stroke-width="1.5"/>+<text x="1041.3333333333335" y="1061" font-family="Sat" font-size="14" fill="#e6edf3" font-weight="500" text-anchor="start" letter-spacing="0">chiplinter</text>+<text x="1041.3333333333335" y="1078" font-family="Sat" font-size="11.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">pad / leg / tab detect</text>+<text x="140" y="1109" font-family="Sat" font-size="13.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">DIVERGENT → concur names the suspect source → chip-fetcher re-fetches it or escalates to you.</text>+<line x1="70" y1="1131" x2="70" y2="1153" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<circle cx="70" cy="1177" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="1184" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">5</text>+<text x="144" y="1173" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">TWEAK · optional, you in the loop</text>+<text x="144" y="1197" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">adjust a version, then re-validate</text>+<rect x="120" y="1221" width="1350" height="76" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="142" y="1239" width="340" height="40" rx="20.0" fill="#0f141c" stroke="#39b8ff" stroke-width="1.4"/>+<circle cx="160" cy="1259.0" r="5" fill="#39b8ff"/>+<text x="174" y="1264.25" font-family="Sat" font-size="15" fill="#cfe6ff" font-weight="500" text-anchor="start" letter-spacing="0">adom-symbol → edit / regroup the symbol</text>+<rect x="502" y="1239" width="330" height="40" rx="20.0" fill="#0f141c" stroke="#8c6bf7" stroke-width="1.4"/>+<circle cx="520" cy="1259.0" r="5" fill="#8c6bf7"/>+<text x="534" y="1264.25" font-family="Sat" font-size="15" fill="#ddd2ff" font-weight="500" text-anchor="start" letter-spacing="0">adom-footprint → edit the footprint</text>+<text x="856" y="1265" font-family="Sat" font-size="13.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">↺ loops back through concur</text>+<line x1="70" y1="1305" x2="70" y2="1329" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<circle cx="70" cy="1355" r="22" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/>+<text x="70" y="1362" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="middle" letter-spacing="0">6</text>+<text x="144" y="1351" font-family="Grot" font-size="26" fill="#e6edf3" font-weight="700" text-anchor="start" letter-spacing="0">PACKAGE + EXPORT · adom-lbr</text>+<text x="144" y="1375" font-family="Sat" font-size="16" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">bundle the chosen sym + fp + 3D + BOM, ship to every tool</text>+<rect x="120" y="1399" width="1350" height="120" rx="14" fill="#11161f" stroke="#222b38" stroke-width="1.5"/>+<rect x="140" y="1417" width="300" height="84" rx="10" fill="#10171f" stroke="#00b8b1" stroke-width="1.6"/>+<text x="158" y="1445" font-family="Grot" font-size="20" fill="#00e6dc" font-weight="700" text-anchor="start" letter-spacing="0">adom-lbr</text>+<text x="158" y="1469" font-family="Sat" font-size="13.5" fill="#aeb8c4" font-weight="400" text-anchor="start" letter-spacing="0">sym + fp + 3D handoff</text>+<text x="158" y="1489" font-family="Sat" font-size="12.5" fill="#8b949e" font-weight="400" text-anchor="start" letter-spacing="0">+ BOM: MF · MPN · Mouser · DigiKey · LCSC</text>+<line x1="440" y1="1459" x2="474.0" y2="1459" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<rect x="480.0" y="1433" width="224.0" height="52" rx="10" fill="#0f141c" stroke="#34d17e" stroke-width="1.7"/>+<text x="592.0" y="1465" font-family="Sat" font-size="16" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">KiCad</text>+<line x1="440" y1="1459" x2="716.0" y2="1459" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<rect x="722.0" y="1433" width="224.0" height="52" rx="10" fill="#0f141c" stroke="#f59e0b" stroke-width="1.7"/>+<text x="834.0" y="1465" font-family="Sat" font-size="16" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Fusion 360</text>+<line x1="440" y1="1459" x2="958.0" y2="1459" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<rect x="964.0" y="1433" width="224.0" height="52" rx="10" fill="#0f141c" stroke="#39b8ff" stroke-width="1.7"/>+<text x="1076.0" y="1465" font-family="Sat" font-size="16" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">Altium</text>+<line x1="440" y1="1459" x2="1200.0" y2="1459" stroke="#3a4658" stroke-width="2.4" marker-end="url(#ah)"/>+<rect x="1206.0" y="1433" width="224.0" height="52" rx="10" fill="#0f141c" stroke="#8c6bf7" stroke-width="1.7"/>+<text x="1318.0" y="1465" font-family="Sat" font-size="16" fill="#e6edf3" font-weight="500" text-anchor="middle" letter-spacing="0">OrCAD</text>+<text x="60" y="1634" font-family="Sat" font-size="15" fill="#00b8b1" font-weight="500" text-anchor="start" letter-spacing="0">wiki.adom.inc/adom/adom-chip-fetcher</text>+</svg>
docs/dashboard-api-contract.mdadded+107@@ -0,0 +1,107 @@+# Dashboard API contract (server side, implemented in src/serve.rs + src/dock.rs)++Every `/api/*`, `/ui/*`, `/state`, `/console`, `/health` response carries+`Access-Control-Allow-Origin: *`; `OPTIONS` on any path answers 204. All paths+are relative to the app root; inside a webview the page MUST use relative URLs+(`API_BASE + '/api/...'`, where `API_BASE` is derived from `location.pathname`+as today).++## Existing (unchanged)++- `GET /api/library`, `GET /api/sources`, `GET /api/validate`, `GET /api/projects`+- `GET /api/chip/<MPN>`, `POST /api/reveal`, `POST /api/open`, `POST /api/orientation`,+ `POST /api/deliver`, `POST /api/validate/<MPN>`, `POST /api/thumbnails/<MPN>`,+ `POST /api/refetch/<MPN>?format=&source=`+- `GET /asset/*`, `GET /thumb/*`, `GET /svg/*`++## Live channel (poll based; 1 s)++- `GET /api/activity` -> the heartbeat payload as today PLUS:+ - `_toasts: [{t, message, kind}]` (kind: `ok` | `err` | `info`), drained on read.+ The page renders each one as a toast.+ - `_ui: [{cmd, ...}]` drained on read. Commands the page MUST handle:+ - `{cmd:"toast", message, kind}` (same as above, alternative path)+ - `{cmd:"click", id}` -> `document.getElementById(id).click()`+ - `{cmd:"select", mpn}` -> select that card (open detail)+ - `{cmd:"view", view}` -> `grid` | `validation` | `sources`+ - `{cmd:"open_settings"}` / `{cmd:"close_settings"}`+ - `{cmd:"zoom", mpn}` / `{cmd:"unzoom"}`+ - `{cmd:"reload"}` -> `location.reload()`+ - `_state_version: <u64>` bumps whenever the library changed on disk (use it to+ refresh the grid instead of refetching /api/library every 4 s).++## Observability++- `GET /state` -> `{ok, app, version, activity, library_count, complete_count,+ active_project, selected_mpn, view, settings_open, ui_reported_at}`. The page+ reports its client state with `POST /state/ui {selected_mpn, view, settings_open,+ zoom_mpn}` whenever it changes (cheap; fire and forget) so the server copy is truth+ for the AI.+- `GET /console?limit=200` -> `{ok, count, items:[{t, level, msg, source}]}`.+- `POST /console {level, msg}` -> the page overrides `console.log/warn/error`,+ `window.onerror`, `unhandledrejection` and posts each line here (source = "ui").++## Dock contract++- `GET /health` -> `{ok:true, app:"adom-chip-fetcher", version}`.+- `GET /api/status` -> `{ok, app, version, state: ok|warn|fail, leds:{<key>:{label,+ state, detail, _hint}}, say:[...], missing_tools:[...]}`. Facet keys: `dashboard`,+ `skill`, `tools`, `bridge`, `library`. Cached 10 s; `?force=1` recomputes.+- `GET /api/say` -> `{ok, phrases:[...]}` (from dockbar.json).+- `POST /api/say/try {phrase}` -> `{staged: bool, where?, reason?}`.++## Settings (settings-layout skill)++- `GET /api/settings` -> `{ok, settings:{...}, defaults:{...}}`.+- `POST /api/settings {<key>: <value>, ...}` -> merge + persist, returns the full+ `settings`. Typed by the default's type; enum values validated.+- `POST /api/settings/reset` -> restore defaults.++Keys (with defaults and the label the UI shows):++| key | default | label | type |+|---|---|---|---|+| `taskbar_progress` | `"indeterminate"` | Taskbar progress during a lookup | enum `indeterminate` / `off` |+| `auto_zoom` | `false` | Auto-zoom the active card | bool |+| `auto_select_active` | `true` | Follow the active lookup | bool |+| `show_stock_badges` | `true` | Stock and lifecycle badges on cards | bool |+| `activity_stale_seconds` | `90` | Heartbeat considered stale after | int |++## Credentials (pup's vault is the store; chip-fetcher keeps none)++- `GET /api/credentials` -> `{ok, source:"pup", credentials:[{host, username}],+ _hint}` (passwords never returned). When the desktop is not connected:+ `{ok:false, error, _hint}`.+- `POST /api/credentials/import {hostFilter:[...]}` -> starts a job (below) running+ pup's browser-login import (one consent dialog + one UAC prompt on the desktop).+- `POST /api/login {source}` -> opens that vendor's sign-in page in the+ chip-fetcher pup profile and asks pup to autofill from its vault; returns+ `{ok, url, job?}`.+- `DELETE /api/credentials/<host>` -> removes from pup's vault.++## Jobs (for Install / Update rows with live consoles)++- `POST /api/jobs {action}` where action is one of `install_tools`+ (`adom-wiki pkg install adom/adom-chip-fetcher`), `install_skill`+ (`adom-chip-fetcher install`), `import_logins` (pup import, optional+ `hostFilter`), `refresh_status`. Returns `{ok, id}`.+- `GET /api/jobs/<id>` -> `{ok, id, action, state: running|done|failed, exit_code,+ lines:[...], started, ended}`; poll every 700 ms and stream `lines` into the+ row's console box.++## UI driving (the CLI calls these; the page only consumes `_ui`)++- `POST /ui/toast {message, kind}`+- `POST /ui/command {cmd, ...}`++## CLI verbs that map to the above++```+adom-chip-fetcher serve [--port 8786] [--print-url]+adom-chip-fetcher health | status | doctor [--fix] | say | try "<phrase>"+adom-chip-fetcher ui toast "<msg>" [--kind ok|err|info]+adom-chip-fetcher ui click <id> | select <MPN> | view <grid|validation|sources>+adom-chip-fetcher ui settings open|close | zoom <MPN> | reload+adom-chip-fetcher settings get | set <key> <value> | reset+adom-chip-fetcher login <source|list|all|import>+```
docs/ui-audit-2026-08-23.mdadded+24@@ -0,0 +1,24 @@+# UI audit of src/ui/index.html (2026-08-23), against adom-ui-design++Linter (adom-ui-linter, ui-patterns/ai-drivability/header): FAIL UI-05 (20x text-transform uppercase), WARN UI-11 title= tooltips, WARN AI-02 no /console, FAIL AI-05/06 no User-Agent gate. The linter is lenient; the real problems are below.++1. Header is three stacked rows (header 549-591, .toolbar 593-609, .project-tabs 611-613, .activity 615-620). header .actions (576) is EMPTY. Fix: ONE 44px three-zone row. Validation / Sourcing order / Architecture / Library folder become 28x28 monochrome icon buttons in .actions with tooltips, plus a settings cog (last). Credentials + auto-zoom move into the settings modal. Search + selects become one slim filter row. Activity bar duplicates the header FETCHING pill (728-731): delete .activity, keep the pill. Project tabs become pills on the filter row.+2. Emoji/symbol glyphs used as icons: 603 ✓, 604 ⛓, 607 🗺, 1104 ⟲, 1105 🖼, 1107 ⤓, 1535 ▾, 1355 ●, 1374 ✕, 1645/1738 ●, 1650 ▾, 1791/1802 ⚠, 1762 ↗, 2102 ✓, 2111 ⬇, 2113 📦/🌉 (34px .bm-icon at 430), 2158 🗺, 2161 ✕, 2194-2257 ✓✗⭳⬇↳▸, badges 1068 Pin-1 ⚠, 1071 Pin-1 ✓, 1085 ✓ GOLDEN, 1090 ⚠ DIVERGENT, 1092 ✗ FAIL, 1094 — UNVAL; tooltips naming glyphs 741, 1094, 1137, 1562/1564; 587-588 ↗. Fix: inline 24-viewBox stroke SVGs with currentColor (the folder/zoom buttons at 606, 1118, 1119 already do it right). Validation table: 14px check/x SVG in --green/--red. Tooltip copy names the control.+3. Em/en dashes (~55 user-visible): 566, 745 (idle subject placeholder), 569, 582 (— x2), 603, 607, 626, 628, 737, 819, 846, 963/1048 (data sentinel '—', check what serve.rs/inventory.rs emit), 1003, 1031, 1037, 1039, 1085-1094, 1119, 1133, 1137, 1142, 1355, 1530, 1600-1602, 1643, 1649, 1795, 1801, 1925-1926, 1939, 1944, 2119, 2158, 2197, 2201, 2215, 2255, 2257, 2265, 2323. Fix: replace with : , . per context; idle subject becomes "No chip selected" in --text-2.+4. Wrong teal #00b8b0 at 6 (favicon) and 60 (--accent); rgba(0,184,176,...) at 131, 257, 367, 368, 379, 393, 520, 532, 534, 1352, 2231, 2278, 2309, 2317. Fix: --accent:#00b8b1; --accent-hover:#00d4cb; --accent-bright:#00e6dc; --on-accent:#05221f; --accent-10/20/35 rgba(0,184,177,.10/.20/.35). Favicon monochrome #e6edf3.+5. Tokens off-spec: 57 --text2 #7d8590 (should be #8b949e). --text3 used as TEXT at 104, 121, 140, 186, 360, 365, 513, 525-526 (2.3:1, never text). --grey #4b5563 as badge text at 382, 387, 388, 392, 402 (the "STEP ?", "stock ?" badges unreadable). No --text-body; body uses --text (too hot). Fix: --text:#e6edf3; --text-body:#c9d1d9; --text-2:#8b949e; --text-3:#484f58; --overlay:#21262d; body{color:var(--text-body)}; unknown badges: background rgba(139,148,158,.12); color var(--text-2); border 1px dashed var(--border).+6. White on teal (2.5:1): 242, 518, 533, 544, 2183, 2289. Fix: color var(--on-accent) (#05221f).+7. No :active compress, zero :focus-visible, outline:none at 96 and 231. Fix: shared block: transitions .15s ease; button:hover background var(--overlay); button:active transform translateY(1px) scale(.97); :focus-visible outline 2px solid var(--accent) offset 2px; .card:active scale(.995).+8. Card clutter: up to 11 badges in six tints (1136-1149). Collapse to one status pill (Full/Stub/Fetching) + one validation pill + a "4/5 files" meter; stock/price/lifecycle go to the detail panel (gated by show_stock_badges setting). 370 .b 10px radius 3px letter-spacing: use radius 999px, padding 2px 8px, 600, no letter-spacing. 255 .mpn needs Familjen Grotesk 600 15px. Hover-only controls (258-259, 281-283, 404-405 opacity:0) become always visible in --text-2, brighten on hover. 1106-1117 native 28px <select> "⤓ re-fetch…" is broken: icon button opening a styled menu (like .src-popover). 353 .thumb:hover scale(1.06) pops out of the card: border-colour change only. 367-368 + 532-534 .card.active defined twice with infinite pulse + WORKING tag: static 2px accent ring. 270-275/1131-1134 three tile heights: SYM / FP / 3D equal tiles in one 3-col row; .lbr becomes an action-row button. 442 dead CSS.+9. Tooltip renderer 2424-2465: hardcoded colours (2426), opacity before measure (2443), no vertical clamp/flip, anchored to element not cursor, no caret. 569 title= and 748. Fix: the pickAnchor() renderer from the tooltips skill (cursor-relative, both-axis clamp M=8, measure after display:block, flip), tokens, text-transform:none; ralph-test the widest tooltip at the four corner cards.+10. Toggles: 2182-2183 / 2288-2289 inline style colour swaps with no aria-pressed; 608 native checkbox (auto-zoom). Fix: .is-on + aria-pressed; auto-zoom becomes a house toggle (role=switch) in settings.+11. Toast 412-414 + 1757-1765: single div, undefined --border2, innerHTML unescaped, no type/icon/stack/pause/ARIA; alert() at 1670, 1692, 2103, 2104, 2137, 2141 and confirm() at 2378. Fix: cfToast(msg,{type:'success'|'error'|'info', action, ttl}); #toasts fixed bottom-center flex column-reverse gap 8px z var(--z-toast); left 3px accent + SVG icon; hover pauses; errors sticky with X; replace every alert/confirm with toasts (Undo for delete). Consume server _toasts from /api/activity.+12. No scrollbar styling anywhere (250 .grid, 484 aside, 494 .filelist nested scroller, 506, 651-652, 307, 2163). .modal (237) radius element scrolls. Fix: scrollbars skill global block; one scroller per region; .modal-body max-height 70vh overflow auto inside overflow:hidden radius element.+13. No phone layout: only media queries 141 and 408. main 249 fixed 1fr 380px; .toolbar wraps to 3-4 rows; .modal min-width 480; .card-btn ~22px tall; 8.5-9px text (456, 281); tooltips disabled on touch with no replacement. Fix: @media (max-width:720px) single column, aside as bottom sheet, modal width calc(100vw - 24px), 44px targets; @media (hover:none) tap on [data-tooltip] opens an info sheet with the text.+14. ALL CAPS + micro type: text-transform uppercase at 418, 500, 2235-2237, 2252, 2299; literal caps 729/735/739, 533, 1037, 1039, 1085-1094, 1137, 1142, 1054-1056 (KICAD/ALTIUM/FUNSION typo), 2200, 2249, 2257, 2265-2266; 8-9.5px at 365, 2265, 456, 281, 358, 364, 446, 475, 2299. Fix: sentence case (Full, Stub, Fetching, Golden, Divergent, Not validated, KiCad, Altium, Fusion, Complete), 11px floor, weight for emphasis.+15. Hardcoded hexes: 302, 331-334, 364, 378, 395, 286-292 (12 one-off chip colours), 412, 420/424/491 (undefined --panel2), 427-438, 456, 473, 516, 2157-2163, 2194/2199/2243 (wrong green fallback #34d17e; token is #3fb950), 2278-2281, 2426; 64 --amber #f59e0b (spec --yellow #d29922). z-index literals 100/200/201/210/9999/99999/100001/100050 (toast above tooltip). 65 inline style= blocks (validation 2194-2269, sources 2297-2326). Fix: full token set + --z-toolbar:80 --z-floating-menu:200 --z-toast:400 --z-modal:600 --z-tooltip:99999; classes for validation/sources; one neutral chip style for 3D sources.+16. AI-drivability: no /state, /console, /ui/toast; polls at 853/855/2184; client-only state (SELECTED 657, DETAIL_TAB 658, project 676, view toggles 2177/2276, SRC_SEL 661, zoom, auto-zoom); no console override. Fix: see docs/dashboard-api-contract.md (server side is being built in parallel).+17. No settings cog/modal. Credentials overlay 234-248/622-646 at 60% dim; bridge modal 427 blur(4px); architecture overlay 2155 blur(3px). Fix: cog last in .actions; settings modal per settings-layout (560-760px, cards with 3px teal edge, full sentences, toggles, Default hints, Install/Update rows with live console, two-click Restore defaults, 25% dim NO blur, Esc/X/outside close, opens at top).+18. Header details: 583-584 hardcoded v0.1.2 / 2026-05-03 (inject from /state.version); 587 deprecated wiki host (use https://wiki.adom.inc/adom/adom-chip-fetcher); 582 — and nine product names; 121 idle '—'; 95-97 underline on brand hover; 558 vs 580 app name inconsistency (use "Chip Fetcher" as the display name); 125-131 .state-pill 10px caps.+19. Fonts: 37-51 JetBrains Mono from jsdelivr (use https://adom.inc/fonts/jetbrains-mono/jetbrains-mono-latin-{400,500}-normal.woff2); no Satoshi 700 but 700/800 used at 173, 183, 414, 431, 2194-2257, 2315, 2323; .mpn (255) and aside h2 (497) lack Familjen. +20. Smaller: 73-74 body flex instead of app-shell position:fixed inset:0 column; 307/1281/1770 duplicated phone detection; 1123 whole .card-desc tooltip duplicates its text; 1762 innerHTML unescaped; 2160/2163 absolute /architecture (use API_BASE); 1669-1670/2378 delete without Undo; 609-613 div tabs with no role/keyboard; 297 pure black backdrop (use rgba(13,17,23,.72)); 2155 + 2426 old palette.
src/bom.rs+2−2@@ -1,8 +1,8 @@ //! Supply-chain (BOM) identifiers for the adom-lbr v1.6.0 contract. //! //! Whenever chip-fetcher shells `adom-lbr generate`/`export` it MUST pass the-//! supply-chain IDs it already looked up — `--mfr`, `--mpn`, `--mouser`,-//! `--digikey`, `--lcsc` — so adom-lbr writes MF/MPN/MOUSER/DIGIKEY/LCSC into+//! supply-chain IDs it already looked up, `--mfr`, `--mpn`, `--mouser`,+//! `--digikey`, `--lcsc`, so adom-lbr writes MF/MPN/MOUSER/DIGIKEY/LCSC into //! the library device <technology>, which Fusion 360 / Altium / OrCAD surface in //! the BOM. Omitting one is only OK when that distributor genuinely has no match //! (adom-lbr just notes it). We read everything we know from `stock.json` (the
src/bridge.rsadded+218@@ -0,0 +1,218 @@+//! Adom Bridge transport: every call to the user's desktop (pup, the vault,+//! file pulls, the taskbar) goes through `adom-bridge --ai-thread "<name>"+//! <verb> '<json>'`. One place owns the thread name, the target pick and the+//! reply shape, so no other module ever spells the CLI out.+//!+//! The ai-thread name (app-creator-ai-thread-name): every call says which+//! conversation is asking. Precedence: the `--ai-thread` global flag (stored+//! into `CHIP_FETCHER_AI_THREAD` by the CLI), then `ADOM_AI_THREAD`, then the+//! app's own name for housekeeping.++use anyhow::{Context, Result};+use serde_json::Value;+use std::process::Command;+use std::sync::OnceLock;++pub const HOUSEKEEPING_THREAD: &str = "chip-fetcher housekeeping";++/// The ai-thread name for outbound bridge calls.+pub fn ai_thread() -> String {+ for k in ["CHIP_FETCHER_AI_THREAD", "ADOM_AI_THREAD"] {+ if let Ok(v) = std::env::var(k) {+ let v = v.trim().to_string();+ if !v.is_empty() {+ return v;+ }+ }+ }+ HOUSEKEEPING_THREAD.to_string()+}++/// Record the `--ai-thread` flag for this process (and every child).+pub fn set_ai_thread(name: &str) {+ if !name.trim().is_empty() {+ std::env::set_var("CHIP_FETCHER_AI_THREAD", name.trim());+ }+}++fn on_path(b: &str) -> bool {+ let home = std::env::var("HOME").unwrap_or_default();+ std::path::Path::new(&home).join(".local/bin").join(b).is_file()+ || std::env::var("PATH").unwrap_or_default().split(':').any(|d| std::path::Path::new(d).join(b).is_file())+}++fn binary() -> &'static str {+ static BIN: OnceLock<&'static str> = OnceLock::new();+ BIN.get_or_init(|| if on_path("adom-bridge") { "adom-bridge" } else { "adom-bridge-cli" })+}++pub fn available() -> bool {+ on_path(binary())+}++/// Explicit desktop pick: `CHIP_FETCHER_TARGET` (set by `--target`).+pub fn target() -> Option<String> {+ std::env::var("CHIP_FETCHER_TARGET").ok().filter(|t| !t.trim().is_empty())+}++pub fn set_target(name: &str) {+ if !name.trim().is_empty() {+ std::env::set_var("CHIP_FETCHER_TARGET", name.trim());+ }+}++/// Resolve the desktop to drive, once per process: the `--target` flag /+/// `CHIP_FETCHER_TARGET`, else the first connected desktop when more than+/// one is on the relay (the bridge refuses to guess; we pick and say so).+pub fn ensure_target() -> Option<String> {+ static RESOLVED: OnceLock<Option<String>> = OnceLock::new();+ RESOLVED+ .get_or_init(|| {+ if let Some(t) = target() {+ return Some(t);+ }+ let all = connected_targets();+ if all.len() > 1 {+ // Pick the desktop the user is actually sitting at: the one+ // with the smallest time since their last keyboard/mouse input.+ let pick = all+ .iter()+ .map(|t| (t.clone(), idle_ms(t).unwrap_or(u64::MAX)))+ .min_by_key(|(_, idle)| *idle)+ .map(|(t, _)| t)+ .unwrap_or_else(|| all[0].clone());+ eprintln!("[adom-chip-fetcher] {} desktops connected ({}); driving {} (where you were last active). Pass --target to choose.", all.len(), all.join(", "), pick);+ set_target(&pick);+ return Some(pick);+ }+ None+ })+ .clone()+}++/// A ready-to-extend `adom-bridge --ai-thread "<name>" [--target <box>]`+/// command. Callers add the verb + args.+pub fn command() -> Command {+ let mut c = Command::new(binary());+ c.arg("--ai-thread").arg(ai_thread());+ if let Some(t) = ensure_target() {+ c.arg("--target").arg(t);+ }+ c+}++/// Call one verb. Resolves `ambiguous_target` (2+ desktops connected) by+/// using `--target` from the environment, else the first connected desktop,+/// and retries once. Returns the bridge envelope as-is+/// (`{success, output, errorCode, _hint, ...}`); use [`output`] to unwrap.+pub fn call(verb: &str, args: &Value) -> Result<Value> {+ call_inner(verb, args, target().as_deref(), true)+}++fn call_inner(verb: &str, args: &Value, target: Option<&str>, allow_retry: bool) -> Result<Value> {+ let mut cmd = Command::new(binary());+ cmd.arg("--ai-thread").arg(ai_thread());+ if let Some(t) = target.map(String::from).or_else(ensure_target) {+ cmd.arg("--target").arg(t);+ }+ cmd.arg(verb).arg(args.to_string());+ let out = cmd.output().with_context(|| format!("running {} {verb}", binary()))?;+ let text = String::from_utf8_lossy(&out.stdout).to_string();+ // The CLI may prefix a "note:" line (retired alias). Find the JSON.+ let json_start = text.find('{').unwrap_or(text.len());+ let v: Value = serde_json::from_str(&text[json_start..]).unwrap_or_else(|_| {+ serde_json::json!({+ "success": false,+ "error": format!("{} {verb}: non-JSON reply: {}", binary(), text.trim().chars().take(300).collect::<String>()),+ "stderr": String::from_utf8_lossy(&out.stderr).trim().to_string(),+ })+ });+ if allow_retry && target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {+ let pick = v+ .get("connected")+ .and_then(|c| c.as_array())+ .and_then(|a| a.first())+ .and_then(|x| x.as_str())+ .map(String::from);+ if let Some(name) = pick {+ set_target(&name);+ return call_inner(verb, args, Some(&name), false);+ }+ }+ Ok(v)+}++/// Unwrap the bridge envelope's `output`, which pup returns as a JSON-encoded+/// string. Falls back to the envelope itself when there is no such field.+pub fn output(v: &Value) -> Value {+ match v.get("output") {+ Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::String(s.clone())),+ Some(o) => o.clone(),+ None => v.clone(),+ }+}++pub fn succeeded(v: &Value) -> bool {+ v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+ || (v.get("error").is_none() && v.get("errorCode").is_none() && v.get("status").and_then(|s| s.as_str()) != Some("error"))+}++pub fn error_text(v: &Value) -> String {+ let code = v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("");+ let err = v.get("error").and_then(|e| e.as_str()).unwrap_or("");+ let hint = v.get("_hint").and_then(|h| h.as_str()).unwrap_or("");+ let mut s = String::new();+ if !code.is_empty() {+ s.push_str(code);+ }+ if !err.is_empty() {+ if !s.is_empty() {+ s.push_str(": ");+ }+ s.push_str(err);+ }+ if !hint.is_empty() {+ s.push_str("\n Hint: ");+ s.push_str(hint);+ }+ if s.is_empty() {+ s = "unknown bridge error".into();+ }+ s+}++/// Milliseconds since the user's last input on one desktop (GetLastInputInfo).+fn idle_ms(target: &str) -> Option<u64> {+ let out = Command::new("timeout")+ .args(["4", binary(), "--ai-thread", &ai_thread(), "--target", target, "desktop_user_idle_ms", "{}"])+ .output()+ .ok()?;+ let text = String::from_utf8_lossy(&out.stdout);+ let v: Value = serde_json::from_str(&text[text.find('{')?..]).ok()?;+ output(&v).get("idleMs").and_then(|n| n.as_u64())+}++/// `adom-bridge targets`: the desktops connected right now.+pub fn connected_targets() -> Vec<String> {+ let out = match Command::new("timeout").args(["3", binary(), "targets"]).output() {+ Ok(o) => o,+ Err(_) => return vec![],+ };+ let text = String::from_utf8_lossy(&out.stdout);+ let start = match text.find('{') {+ Some(i) => i,+ None => return vec![],+ };+ let v: Value = match serde_json::from_str(&text[start..]) {+ Ok(v) => v,+ Err(_) => return vec![],+ };+ v.get("targets")+ .and_then(|t| t.as_array())+ .map(|a| {+ a.iter()+ .filter_map(|t| t.get("name").or_else(|| t.get("hostname")).and_then(|n| n.as_str()).map(String::from))+ .collect()+ })+ .unwrap_or_default()+}
src/cli.rs+547−213@@ -6,7 +6,7 @@ use crate::{hints, import, inventory, library, pup, serve, sources, sourcing, th const SKILL_MD: &str = include_str!("../SKILL.md"); -// Playbooks — deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read.+// Playbooks, deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read. // Embedded at compile time so `adom-chip-fetcher install` deploys them alongside SKILL.md without // needing the source repo on the user's container. const PLAYBOOKS: &[(&str, &str)] = &[@@ -17,7 +17,7 @@ const PLAYBOOKS: &[(&str, &str)] = &[ ("mouser-cse.md", include_str!("../playbooks/mouser-cse.md")), ("digikey-models.md", include_str!("../playbooks/digikey-models.md")), ("credentials-and-logins.md", include_str!("../playbooks/credentials-and-logins.md")),- ("adom-desktop-api.md", include_str!("../playbooks/adom-desktop-api.md")),+ ("adom-bridge-api.md", include_str!("../playbooks/adom-bridge-api.md")), ("handoff-and-watching.md", include_str!("../playbooks/handoff-and-watching.md")), ("jst.md", include_str!("../playbooks/jst.md")), ("jst-email-retrieval.md", include_str!("../playbooks/jst-email-retrieval.md")),@@ -29,6 +29,15 @@ const PROFILE: &str = "adom-chip-fetcher"; #[derive(Parser)] #[command(name = "adom-chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")] struct Cli {+ /// The ai-thread name: which conversation is asking. Sent on every+ /// Adom Bridge / pup call so the user can tell your window from the+ /// other twenty. Use a short human-recognizable name, reused on every+ /// call ("chip-fetcher board review"), never "agent" or a UUID.+ #[arg(long, global = true)]+ ai_thread: Option<String>,+ /// Desktop to drive when more than one is connected (`adom-bridge targets`).+ #[arg(long, global = true)]+ target: Option<String>, #[command(subcommand)] cmd: Cmd, }@@ -36,8 +45,7 @@ struct Cli { #[derive(Subcommand)] enum Cmd { /// PLAN a batch: show the user the full queue up front + seed planned-state- /// dashboard cards, BEFORE any scraping. This is the required first step —- /// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN+ /// dashboard cards, BEFORE any scraping. This is the required first step, /// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN /// that isn't in the active plan. That's the guardrail that stops freelancing. Plan { /// The MPNs to source, in order.@@ -46,13 +54,15 @@ enum Cmd { #[arg(long, default_value = "batch")] name: String, },- /// Scan the native browser's SAVED LOGINS across every profile (Chrome +- /// Edge), map them to the sourcing ladder, and report which CAD/distributor- /// vendors you're signed in to + the best profile. adom-chip-fetcher uses this to- /// self-drive: it auto-switches to the vendor-authed profile while sourcing.- Logins,+ /// What vendor logins pup's vault holds, and what your real browser+ /// (Chrome / Edge / Brave) has saved. --import pulls the vendor ones into+ /// pup's OS-keychain vault (one consent dialog + one UAC on the desktop).+ Logins {+ #[arg(long)]+ import: bool,+ }, /// Fetch a part's full CAD bundle (symbol+footprint+STEP) from Component- /// Search Engine via XHR + basic-auth — no browser download, no prompts, no+ /// Search Engine via XHR + basic-auth, no browser download, no prompts, no /// questions. ST/TI/NXP/etc. route their EDA models here. First-class + /// permanent: works for any user with a CSE login in the credential vault. Cse {@@ -71,11 +81,11 @@ enum Cmd { /// Enrich a chip from the Adom parts tools (adom-parts-search → Mouser + /// DigiKey + JLCPCB): writes the description, manufacturer, stock.json /// (stock + pricing + lifecycle) and lists orderable package variants.- /// Keys are baked into those tools' own services — NO API key needed.+ /// Keys are baked into those tools' own services, NO API key needed. Enrich { mpn: String }, /// Run the MOST authoritative source: extract symbol + footprint from the /// manufacturer DATASHEET via adom-ds2sf. GUARANTEES the datasheet is real- /// (multi-page + pinout) first — refuses a 1-page denial page — then runs+ /// (multi-page + pinout) first, refuses a 1-page denial page, then runs /// the extraction and renders the ds2sf variant thumbnails. Ds2sf { mpn: String }, /// Fetch one MPN from the best available source@@ -86,11 +96,20 @@ enum Cmd { project: Option<String>, }, /// Query the Adom Wiki ONLY (ladder step 0) and reuse a published bundle if- /// one exists. Same code path `fetch` runs first — exposed standalone so the+ /// one exists. Same code path `fetch` runs first, exposed standalone so the /// wiki reuse can be tested without driving the whole vendor ladder. WikiFetch { mpn: String },- /// Open a source's login page so the user can authenticate once- Login { source: String },+ /// Sign in to a vendor from pup's OS-keychain vault: opens the source's+ /// sign-in page in the chip-fetcher pup window and runs pup_login. Pass+ /// --user/--pass the first time (pup stores them; chip-fetcher never does).+ /// `login list` prints the catalog, `login all` does every source.+ Login {+ source: String,+ #[arg(long)]+ user: Option<String>,+ #[arg(long)]+ pass: Option<String>,+ }, /// Show what's already in the library List, /// Show what's downloaded vs missing for a single MPN@@ -144,24 +163,63 @@ enum Cmd { /// Deploy SKILL.md to ~/.claude/skills/adom-chip-fetcher/ so Claude knows how to use this tool. /// Run after `curl ... | install` to finish setup. Install,- /// Run the viewer HTTP server. Open in any browser at http://localhost:<port>.+ /// Run the dashboard server (idempotent, version-aware). With+ /// --print-url it prints the direct http://127.0.0.1:<port>/ URL,+ /// self-backgrounds and exits 0 (the Hydrogen Dock Bar contract). Serve { #[arg(long, default_value_t = 8786)] port: u16,+ /// Print the reachable URL and keep serving in the background.+ #[arg(long)]+ print_url: bool,+ #[arg(long, hide = true)]+ detached_child: bool, },- /// Start the viewer + open it in a Hydrogen webview tab.+ /// Start the dashboard + open it in a MAXIMIZED pup window (the user's+ /// preferred surface). Reuses the window when it is already open. App { #[arg(long, default_value_t = 8786)] port: u16, },+ /// Is the dashboard up? Prints {ok, app, version} from /health.+ Health {+ #[arg(long, default_value_t = 8786)]+ port: u16,+ },+ /// First-run check: the binary, the AI skill, the thirteen sister tools,+ /// the desktop bridge, pup, the library. One line per component with a+ /// Hint for anything not green. --fix installs what is missing.+ Doctor {+ /// Install missing pieces (sister tools via the wiki, the skill).+ #[arg(long)]+ fix: bool,+ #[arg(long, default_value_t = 8786)]+ port: u16,+ },+ /// The phrases users say to their AI to drive this app (from dockbar.json).+ Say,+ /// Stage a phrase into this app's own AI thread (copy fallback).+ Try { phrase: String },+ /// Drive the dashboard's front-end over the live channel.+ Ui {+ #[command(subcommand)]+ op: UiOp,+ #[arg(long, global = true, default_value_t = 8786)]+ port: u16,+ },+ /// Read or change settings (stored in ~/.config/adom-chip-fetcher/settings.json).+ Settings {+ #[command(subcommand)]+ op: SettingsOp,+ }, /// Heartbeat the dashboard. Run before every state change so users see /// live progress. Stages are short labels: "navigating UL", "captcha- /// solved", "downloading STEP", "imported", "stuck — needs login", etc.+ /// solved", "downloading STEP", "imported", "stuck, needs login", etc. Heartbeat { mpn: String, stage: String, },- /// Verify the rails before a fetch — dashboard reachable, heartbeat+ /// Verify the rails before a fetch, dashboard reachable, heartbeat /// fresh, pup session 'adom-chip-fetcher' open. Fails with HINT lines so the /// next step is mechanical, not remembered. Selfcheck,@@ -169,14 +227,13 @@ enum Cmd { /// Useful when an AI agent has lost the thread mid-batch. Hint, /// Print the canonical sourcing-ladder priority order (Mfr → SnapMagic →- /// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT —- /// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE+ /// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT, /// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE /// falling back from one source to the next; do NOT skip ahead to LCSC. Sources, /// Hard gate: scan every chip in library/ and refuse to declare success /// while ANY card is incomplete (stub-only or missing core CAD). Returns /// non-zero exit when work remains. AI: ALWAYS run `adom-chip-fetcher gate`- /// before reporting "done" on a batch — it prints exactly which files+ /// before reporting "done" on a batch, it prints exactly which files /// each card is missing, so claiming done is mechanical, not remembered. Gate { /// Strict mode: also require Altium IntLib + Fusion .lbr. Default@@ -188,7 +245,7 @@ enum Cmd { mpn: Option<String>, }, /// Backfill provenance for files that landed without going through import- /// (so their source isn't a bare "—"). Derives each present-but-+ /// (so their source isn't a bare ","). Derives each present-but- /// un-provenanced file's origin from info.json (step_source / the /// sourcing-chain entry that succeeded) and records it; flags what it can't. BackfillProvenance {@@ -198,7 +255,7 @@ enum Cmd { }, /// Detect manufacturer from MPN and open their canonical product / /// search page in pup. AI: run this BEFORE any third-party source.- /// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe+ /// The mfr-first rule is enforced in code, `adom-chip-fetcher mfr-probe /// <MPN>` either opens the right URL or tells you we can't infer the /// manufacturer (in which case the AI must do its own probe). Auto- /// records the manufacturer-attempt on info.json.fetched_via_chain.@@ -225,12 +282,12 @@ enum Cmd { /// status to <MPN>-validation-status.json. Surfaces to human cleanly /// when out of automated options. Validate {- /// Chip MPN — directory must already exist at library/<MPN>/.+ /// Chip MPN, directory must already exist at library/<MPN>/. mpn: String, }, /// Drive the adom-chip-fetcher pup scrape session. Every action auto- /// heartbeats the dashboard, always uses `profile: "adom-chip-fetcher"`- /// (NEVER a different profile — that loses vendor login cookies),+ /// (NEVER a different profile, that loses vendor login cookies), /// and logs every step to the fetched_via_chain. The AI calls these /// instead of raw `adom-desktop browser_*` calls so the dashboard /// shows live progress and credentials are never lost.@@ -240,7 +297,7 @@ enum Cmd { }, /// Manage stored vendor credentials. Saved to /// ~/.config/adom-chip-fetcher/credentials.json on the user's container.- /// Survives across sessions — the user enters creds ONCE, adom-chip-fetcher+ /// Survives across sessions, the user enters creds ONCE, adom-chip-fetcher /// can re-login automatically when cookies expire. Creds { #[command(subcommand)]@@ -278,7 +335,7 @@ enum Cmd { /// fingerprint preserved), base64-transfer the bytes back, magic-byte /// validate, and save to `library/<MPN>/<MPN>.pdf`. The escape hatch /// for vendors that detect curl and serve HTML to it (Hanrun's Aliyun- /// CDN, ST CDN, JST product pages, etc.) — proven pattern from the+ /// CDN, ST CDN, JST product pages, etc.), proven pattern from the /// 2026-05-07 HR911105A recovery. PupPdfFetch { /// MPN to file the PDF under.@@ -299,7 +356,7 @@ enum Cmd { /// `manufacturer`. #[arg(long, default_value = "manufacturer")] source: String,- /// Pup session id (default: `cf-pdf-grab` — kept separate from+ /// Pup session id (default: `cf-pdf-grab`, kept separate from /// the dashboard `adom-chip-fetcher` and the scrape `adom-chip-fetcher-scrape` /// sessions so this can run alongside an existing fetch). #[arg(long, default_value = "cf-pdf-grab")]@@ -308,7 +365,7 @@ enum Cmd { /// Re-fetch a specific library file from a specific source. Drives the /// vendor's product/CAD page in pup, watches ~/Downloads for the /// resulting bundle, and imports it. Used by `adom-chip-fetcher validate`- /// when concur returns `refetch_library_source` — the loop calls this+ /// when concur returns `refetch_library_source`, the loop calls this /// as a subprocess so a divergent kicad_sym can be replaced with one /// from a different upstream without a human in the loop. ///@@ -332,7 +389,7 @@ enum Cmd { #[arg(long, default_value_t = 180)] timeout: u64, },- /// Manage the dashboard surface — auto-(re)open it (default), force a+ /// Manage the dashboard surface, auto-(re)open it (default), force a /// fresh window, or set the surface preference. Dashboard { #[command(subcommand)]@@ -349,7 +406,7 @@ enum Cmd { #[arg(long)] force: bool, },- /// Manage projects — scope adom-chip-fetcher work to a named project so the+ /// Manage projects, scope adom-chip-fetcher work to a named project so the /// dashboard shows only the chips you care about right now. Project { #[command(subcommand)]@@ -402,7 +459,7 @@ enum DashboardOp { } /// Every pup action the AI drives during a fetch. ALWAYS uses-/// `profile: "adom-chip-fetcher"` — hardcoded, non-overridable. This+/// `profile: "adom-chip-fetcher"`, hardcoded, non-overridable. This /// preserves vendor login cookies across sessions and ensures the /// dashboard heartbeat fires on every action. #[derive(Subcommand)]@@ -447,10 +504,77 @@ enum CredsOp { Delete { host: String }, } +#[derive(Subcommand)]+enum UiOp {+ /// Raise a toast over the dashboard (the AI's narration channel).+ Toast {+ message: String,+ /// ok | err | info+ #[arg(long, default_value = "info")]+ kind: String,+ },+ /// Click an element by id.+ Click { id: String },+ /// Select a card (opens its detail).+ Select { mpn: String },+ /// Switch the view: grid | validation | sources.+ View { view: String },+ /// Open or close the settings modal.+ Settings { state: String },+ /// Zoom a card.+ Zoom { mpn: String },+ /// Leave the zoom view.+ Unzoom,+ /// Reload the page.+ Reload,+ /// Print the server's view of the UI (GET /state).+ State,+ /// Print the UI console ring buffer (GET /console).+ Console {+ #[arg(long, default_value_t = 100)]+ limit: usize,+ },+}++#[derive(Subcommand)]+enum SettingsOp {+ /// Print every setting with its default.+ Get,+ /// Set one setting (validated against its type / enum).+ Set { key: String, value: String },+ /// Restore every setting to its default.+ Reset,+}+ pub fn run() -> Result<()> { let cli = Cli::parse();+ if let Some(t) = cli.ai_thread.as_deref() {+ crate::bridge::set_ai_thread(t);+ }+ if let Some(t) = cli.target.as_deref() {+ crate::bridge::set_target(t);+ } match cli.cmd {- Cmd::Logins => cmd_logins(),+ Cmd::Logins { import } => cmd_logins(import),+ Cmd::Health { port } => health_cmd(port),+ Cmd::Doctor { fix, port } => doctor_cmd(fix, port),+ Cmd::Say => {+ for p in crate::dock::say_phrases() {+ println!("{p}");+ }+ Ok(())+ }+ Cmd::Try { phrase } => {+ let r = crate::dock::try_phrase(&phrase);+ if r.get("staged").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!("OK: staged in this app's AI thread ({}); press Enter there to send", r.get("where").and_then(|w| w.as_str()).unwrap_or("thread"));+ } else {+ println!("OK: not staged ({}). Paste it into your AI instead:\n {phrase}", r.get("reason").and_then(|w| w.as_str()).unwrap_or("unreachable"));+ }+ Ok(())+ }+ Cmd::Ui { op, port } => ui_cmd(op, port),+ Cmd::Settings { op } => settings_cmd(op), Cmd::Cse { mpn, mfr } => cmd_cse(&mpn, mfr.as_deref()), Cmd::Describe { mpn, text } => cmd_describe(&mpn, &text), Cmd::Enrich { mpn } => cmd_enrich(&mpn),@@ -466,7 +590,7 @@ pub fn run() -> Result<()> { fetch(&mpn) } Cmd::WikiFetch { mpn } => wiki_fetch_cmd(&mpn),- Cmd::Login { source } => login(&source),+ Cmd::Login { source, user, pass } => login_source(&source, user.as_deref(), pass.as_deref()), Cmd::List => list(), Cmd::Status { mpn } => status(&mpn), Cmd::Import { path, mpn, no_validate, discovery_source, content_origin, source_url } =>@@ -474,7 +598,7 @@ pub fn run() -> Result<()> { Cmd::Pull { remote_path, mpn, no_validate, discovery_source, content_origin, source_url } => pull_cmd(&remote_path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url), Cmd::Install => install_cmd(),- Cmd::Serve { port } => serve::run(port),+ Cmd::Serve { port, print_url, detached_child } => serve::run(port, print_url, detached_child), Cmd::App { port } => app_cmd(port), Cmd::Heartbeat { mpn, stage } => hints::heartbeat(&mpn, &stage), Cmd::Selfcheck => hints::selfcheck(),@@ -504,27 +628,34 @@ pub fn run() -> Result<()> { /// what unblocked the VL53Lx family. fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> { require_planned(mpn)?;- let cred = crate::creds::load()?.into_iter()- .find(|c| c.host.contains("componentsearchengine"))- .ok_or_else(|| anyhow::anyhow!(- "no Component Search Engine credential in the vault.\n \- HINT: sign in to componentsearchengine.com once in your browser, or \- `adom-chip-fetcher creds save componentsearchengine <user> <pass>`"- ))?;+ // CSE is HTTP Basic Auth. pup's vault answers it for *.componentsearchengine.com+ // (page.authenticate on every navigate), so there is nothing to look up here;+ // a missing credential surfaces as a non-zip reply, which the hint below explains. let mfr = mfr.map(String::from) .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.manufacturer.to_string())) .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass --mfr <name>"))?;- // Keep the dashboard "working on" bar LIVE for the whole browser fetch —- // cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would+ // Keep the dashboard "working on" bar LIVE for the whole browser fetch, // cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would // go stale mid-fetch. The ticker re-pulses every 6s until it's stopped. let ticker = hints::HeartbeatTicker::start(mpn, &format!("CSE: fetching CAD via your browser ({mfr})"));- let bytes = pup::cse_fetch_zip(mpn, &mfr, &cred.username, &cred.password)?;+ let bar = crate::taskbar::ProgressRun::start("cse", &format!("{mpn}: CSE CAD bundle"));+ let bytes = match pup::cse_fetch_zip(mpn, &mfr) {+ Ok(b) => b,+ Err(e) => {+ ticker.stop();+ anyhow::bail!(+ "{e}\n Hint: CSE needs a *.componentsearchengine.com login in pup's vault. \+ Run `adom-chip-fetcher login componentsearch --user <email> --pass <pw>` once, \+ or `adom-chip-fetcher logins --import` to pull it from your browser."+ );+ }+ }; ticker.stop();+ bar.finish(); let zip = std::env::temp_dir().join(format!("cse_{mpn}.zip")); std::fs::write(&zip, &bytes)?; // Keep the ORIGINAL downloaded archive in the library so the user can // inspect the exact bytes we pulled off the internet (folder structure, all- // EDA formats, the 3D, etc.) — not just the files we extracted from it.+ // EDA formats, the 3D, etc.), not just the files we extracted from it. let orig = library::root().join(mpn).join(format!("{mpn}.cse-bundle.zip")); let _ = std::fs::create_dir_all(orig.parent().unwrap()); let _ = std::fs::write(&orig, &bytes);@@ -533,9 +664,9 @@ fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> { import_cmd(&zip, Some(mpn), false, "cse", "manufacturer", "")?; let _ = hints::heartbeat(mpn, "imported"); // First-class metadata: pull description + stock + pricing from the Adom- // parts tools (keys baked in — no API key). Best-effort; never fails the run.+ // parts tools (keys baked in, no API key). Best-effort; never fails the run. auto_enrich(mpn);- // The DATASHEET is the most authoritative source — ALWAYS run ds2sf so every+ // The DATASHEET is the most authoritative source, ALWAYS run ds2sf so every // fetched chip gets the independent datasheet extraction. Best-effort: a // ds2sf failure doesn't fail the CAD fetch, but the gate WILL flag it (so it // can never be silently skipped). Set CHIP_FETCHER_SKIP_DS2SF=1 to opt out.@@ -555,7 +686,7 @@ fn auto_enrich(mpn: &str) { match crate::enrich::enrich(mpn) { Ok(r) => { if let Some(d) = &r.description {- println!(" enrich: description set — {}", d.chars().take(70).collect::<String>());+ println!(" enrich: description set, {}", d.chars().take(70).collect::<String>()); } if r.wrote_stock { println!(" enrich: stock.json written (stock + pricing + lifecycle)"); } }@@ -573,16 +704,16 @@ fn chip_description(mpn: &str) -> String { } /// After sourcing, nudge the AI to set a human description if the chip has none-/// — the card + zoom view show it, and a blank "what is this chip" reads as+///, the card + zoom view show it, and a blank "what is this chip" reads as /// broken. Self-enforcing, same spirit as the gate/completeness warnings. fn warn_if_no_description(mpn: &str) { if chip_description(mpn).trim().is_empty() {- println!("HINT: {mpn} has no description — the card/zoom view will show a blank \+ println!("HINT: {mpn} has no description, the card/zoom view will show a blank \ 'what is this chip'. Set one: `adom-chip-fetcher describe {mpn} \"<one-line what it is>\"`"); } } -/// Run adom-ds2sf on a chip — the datasheet IS the most authoritative source.+/// Run adom-ds2sf on a chip, the datasheet IS the most authoritative source. /// Hard-guarantees a real datasheet first, then extracts + renders the variant. fn cmd_ds2sf(mpn: &str) -> Result<()> { let dir = library::root().join(mpn);@@ -590,14 +721,15 @@ fn cmd_ds2sf(mpn: &str) -> Result<()> { let pdf = dir.join(format!("{mpn}.pdf")); // GUARANTEE the datasheet is usable BEFORE spending a Claude extraction on it. match crate::import::datasheet_ready(&pdf) {- Ok(q) => println!("OK: datasheet ready — {} pages, pinout detected", q.pages),+ Ok(q) => println!("OK: datasheet ready, {} pages, pinout detected", q.pages), Err(reason) => {- hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf — {reason}. \+ hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf, {reason}. \ Re-fetch a real datasheet (`adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <mfr lit URL>`), then re-run.")); anyhow::bail!("ds2sf needs a real datasheet for {mpn}: {reason}"); } } let _ = hints::heartbeat(mpn, "ds2sf: extracting symbol+footprint from datasheet");+ let bar = crate::taskbar::ProgressRun::start("ds2sf", &format!("{mpn}: datasheet extraction")); let status = std::process::Command::new("adom-ds2sf") .args(["extract"]).arg(&dir).arg("--force").arg("--json-only") .status()@@ -621,8 +753,9 @@ fn cmd_ds2sf(mpn: &str) -> Result<()> { } // Render the ds2sf variant thumbnails so the carat + validation surface them. auto_render_thumbnails(mpn);+ bar.finish(); let _ = hints::heartbeat(mpn, "ds2sf: extracted");- println!("OK: {mpn} — ds2sf symbol + footprint extracted from the datasheet");+ println!("OK: {mpn}, ds2sf symbol + footprint extracted from the datasheet"); Ok(()) } @@ -630,8 +763,8 @@ fn cmd_ds2sf(mpn: &str) -> Result<()> { fn cmd_enrich(mpn: &str) -> Result<()> { let r = crate::enrich::enrich(mpn)?; match &r.description {- Some(d) => println!("OK: {mpn} description — {d}"),- None => println!("WARN: {mpn} — no description found via adom-parts-search"),+ Some(d) => println!("OK: {mpn} description, {d}"),+ None => println!("WARN: {mpn}, no description found via adom-parts-search"), } if r.wrote_stock { println!("OK: {mpn} stock.json written (stock + pricing + lifecycle)"); } if !r.variants.is_empty() {@@ -645,7 +778,7 @@ fn cmd_enrich(mpn: &str) -> Result<()> { fn cmd_describe(mpn: &str, text: &str) -> Result<()> { let dir = library::root().join(mpn); if !dir.exists() {- anyhow::bail!("library/{mpn}/ does not exist — source the chip first");+ anyhow::bail!("library/{mpn}/ does not exist, source the chip first"); } let p = dir.join("info.json"); let mut info: serde_json::Value = std::fs::read_to_string(&p).ok()@@ -660,54 +793,52 @@ fn cmd_describe(mpn: &str, text: &str) -> Result<()> { /// First-class credential awareness: scan saved logins across every browser /// profile, map to the ladder, report coverage + the best profile. The sourcing /// flow auto-switches profiles per vendor using the same data.-fn cmd_logins() -> Result<()> {- use std::collections::{BTreeMap, BTreeSet};- let creds = pup::credentials();- let mut by: BTreeMap<String, BTreeMap<&str, BTreeSet<String>>> = BTreeMap::new();- for c in creds {- for (frag, name) in pup::VENDOR_HOSTS {- if c.host.contains(frag) {- by.entry(format!("{} · {}", c.browser, c.profile))- .or_default().entry(*name).or_default()- .insert(if c.username.is_empty() { "(saved)".into() } else { c.username.clone() });- }- }- }- if by.is_empty() {- println!("No saved CAD/distributor logins found in the native browser.");- println!("HINT: install/enable the Adom browser extension, or sign in to a vendor once.");+fn cmd_logins(import: bool) -> Result<()> {+ let v = crate::vault::list()?;+ if !v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!("ERROR: {}", v.get("error").and_then(|e| e.as_str()).unwrap_or("vault unreachable"));+ hints::print_hint(v.get("_hint").and_then(|h| h.as_str()).unwrap_or("open Adom Hydrogen on your desktop")); return Ok(()); }- let mut ranked: Vec<_> = by.into_iter().collect();- ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));- println!("adom-chip-fetcher — saved CAD/distributor logins (read-only; never passwords):");- for (prof, vmap) in &ranked {- println!("\n [{prof}] — {} vendor(s):", vmap.len());- for (name, users) in vmap {- println!(" {name:16} {}", users.iter().cloned().collect::<Vec<_>>().join(", "));- }- }- let (best, bv) = &ranked[0];- println!("\nBEST profile for sourcing: {best} ({} vendors). adom-chip-fetcher auto-switches \- to the right signed-in profile per vendor as it sources — no questions.", bv.len());-- // Per-BROWSER score: which native browser (Chrome vs Edge) covers the most- // of the electronics industry across all its profiles.- let total_vendors = pup::VENDOR_HOSTS.iter().map(|(_, n)| *n).collect::<BTreeSet<_>>().len();- let mut by_browser: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();- for c in creds {- for (frag, name) in pup::VENDOR_HOSTS {- if c.host.contains(frag) { by_browser.entry(c.browser.clone()).or_default().insert(*name); }+ let creds = v.get("credentials").and_then(|c| c.as_array()).cloned().unwrap_or_default();+ println!("pup vault: {} vendor login(s) (passwords never shown)", creds.len());+ for c in &creds {+ println!(" {:<34} user={}", c.get("host").and_then(|h| h.as_str()).unwrap_or("?"), c.get("username").and_then(|u| u.as_str()).unwrap_or("?"));+ }+ let scan = crate::vault::import_scan()?;+ let total = scan.get("totalPasswords").and_then(|n| n.as_u64()).unwrap_or(0);+ if let Some(profiles) = scan.get("profiles").and_then(|p| p.as_array()) {+ println!("\nyour browser profiles on the desktop ({} saved passwords in total):", total);+ for p in profiles {+ println!(+ " {:<8} {:<20} {} passwords {}",+ p.get("browserName").or_else(|| p.get("browser")).and_then(|b| b.as_str()).unwrap_or("?"),+ p.get("profileName").or_else(|| p.get("profileDir")).and_then(|b| b.as_str()).unwrap_or("?"),+ p.get("passwordCount").and_then(|n| n.as_u64()).unwrap_or(0),+ p.get("accounts").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()+ );+ }+ } else if let Some(e) = scan.get("error") {+ println!("\nWARN: could not scan the browser profiles: {e}");+ }+ if !import {+ if total > 0 {+ hints::print_hint("pull the vendor logins into pup's vault: adom-chip-fetcher logins --import (one consent dialog + one UAC prompt on the desktop)"); }+ return Ok(()); }- let mut bscore: Vec<_> = by_browser.into_iter().collect();- bscore.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));- println!("\n=== Native-browser industry-coverage score ===");- for (br, v) in &bscore {- println!(" {:6} {}/{} vendors ({})", br, v.len(), total_vendors, v.iter().cloned().collect::<Vec<_>>().join(", "));- }- if let Some((winner, v)) = bscore.first() {- println!("\nWINNER: drive in **{winner}** — broadest electronics-industry login coverage ({}/{} vendors).", v.len(), total_vendors);+ println!("\nimporting vendor logins into pup's OS-keychain vault. Approve the Adom Bridge consent dialog and the Windows UAC prompt on your desktop...");+ let r = crate::vault::import_browser_logins(&[])?;+ if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!(+ "OK: imported {} new, {} updated, {} skipped",+ r.get("added").and_then(|n| n.as_u64()).unwrap_or(0),+ r.get("updated").and_then(|n| n.as_u64()).unwrap_or(0),+ r.get("skipped").and_then(|n| n.as_u64()).unwrap_or(0)+ );+ hints::print_hint("now `adom-chip-fetcher login <source>` signs in with no typing");+ } else {+ println!("ERROR: {}", r.get("error").and_then(|e| e.as_str()).unwrap_or("import failed")); } Ok(()) }@@ -723,28 +854,28 @@ fn cmd_plan(mpns: &[String], name: &str) -> Result<()> { if let Err(e) = hints::dashboard_ensure(false) { println!("WARN: could not auto-open the dashboard ({e}); start it with `adom-chip-fetcher serve`"); }- println!("PLAN '{name}' — {} chip(s) queued (the user sees these as planned cards now):", mpns.len());+ println!("PLAN '{name}', {} chip(s) queued (the user sees these as planned cards now):", mpns.len()); for (i, mpn) in mpns.iter().enumerate() { let _ = crate::projects::add_mpn(name, mpn); let _ = hints::heartbeat(mpn, "planned"); // side-effect: seeds the stub card println!(" {}. {mpn}", i + 1); }- println!("\nNow source them — manufacturer-first, and every verb heartbeats live to the dashboard:");+ println!("\nNow source them, manufacturer-first, and every verb heartbeats live to the dashboard:"); println!(" adom-chip-fetcher mfr-probe <MPN> # required first source (the maker)"); println!(" adom-chip-fetcher next-source <MPN> # the next ladder step to try"); println!(" adom-chip-fetcher gate # refuses 'done' while any card is incomplete"); Ok(()) } -/// PREFLIGHT GUARD — every browser-driving verb (fetch / mfr-probe / scrape)+/// PREFLIGHT GUARD, every browser-driving verb (fetch / mfr-probe / scrape) /// calls this FIRST. It refuses (non-zero exit + HINT) to scrape any MPN the /// user hasn't seen in an active plan, and refuses if the dashboard isn't live /// to show working status. This is the programmatic backstop that makes-/// "willy-nilly AI scraping with no insight" structurally impossible — for this+/// "willy-nilly AI scraping with no insight" structurally impossible, for this /// agent and for every third party that drives the tool. fn require_planned(mpn: &str) -> Result<()> { let active = crate::projects::get_active().ok_or_else(|| anyhow::anyhow!(- "REFUSED: no active plan — the user must see the queue before any scraping.\n \+ "REFUSED: no active plan, the user must see the queue before any scraping.\n \ HINT: adom-chip-fetcher plan {mpn} [more MPNs...] then source." ))?; if !crate::projects::mpns_for_project(&active).iter().any(|m| m == mpn) {@@ -756,7 +887,7 @@ fn require_planned(mpn: &str) -> Result<()> { if !dashboard_reachable() { anyhow::bail!( "REFUSED: the dashboard isn't reachable on :8786, so the user would get no working-status insight.\n \- HINT: adom-chip-fetcher serve (then retry) — the dashboard is the user's home base."+ HINT: adom-chip-fetcher serve (then retry), the dashboard is the user's home base." ); } Ok(())@@ -787,7 +918,7 @@ fn project_cmd(op: ProjectOp) -> Result<()> { } for p in &projs { let marker = if active.as_deref() == Some(&p.name) { " (active)" } else { "" };- println!(" {} — {} chips{}", p.name, p.mpns.len(), marker);+ println!(" {}, {} chips{}", p.name, p.mpns.len(), marker); } } ProjectOp::Show { name } => {@@ -816,7 +947,7 @@ fn project_cmd(op: ProjectOp) -> Result<()> { } ProjectOp::Deactivate => { projects::set_active(None)?;- println!("OK: active project cleared — dashboard shows full library");+ println!("OK: active project cleared, dashboard shows full library"); } ProjectOp::Delete { name } => { projects::delete_project(&name)?;@@ -845,7 +976,7 @@ fn thumbnails_cmd(mpn: Option<&str>, force: bool) -> Result<()> { total_err += r.errors.len(); } println!();- println!("OK: {} chips processed — {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len());+ println!("OK: {} chips processed, {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len()); hints::print_hint("open the dashboard to see them: `adom-chip-fetcher dashboard ensure`"); } Ok(())@@ -893,7 +1024,7 @@ fn dashboard_dispatch(op: DashboardOp) -> Result<()> { /// Full completeness ladder for one chip or the whole library. /// 9 levels, each checked from the filesystem. The AI reads this to-/// decide what to do next — no guessing, no "I think it's done."+/// decide what to do next, no guessing, no "I think it's done." fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { let lib = inventory::scan()?; let chips: Vec<_> = lib.chips.into_iter()@@ -928,7 +1059,7 @@ fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { ]; let done_count = levels.iter().filter(|(_, _, ok, _)| *ok).count();- println!("{} — {}/9 levels complete", c.mpn, done_count);+ println!("{}, {}/9 levels complete", c.mpn, done_count); for (num, name, ok, fix) in &levels { let mark = if *ok { "✓" } else { "✗" }; let fix_hint = if fix.is_empty() { String::new() } else { format!(" → {fix}") };@@ -938,7 +1069,7 @@ fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { let next = levels.iter().find(|(_, _, ok, _)| !*ok); if let Some((_, name, _, fix)) = next { println!();- hints::print_hint(&format!("next step for {}: {} — {}", c.mpn, name, fix));+ hints::print_hint(&format!("next step for {}: {}, {}", c.mpn, name, fix)); } } println!();@@ -963,7 +1094,7 @@ fn done_cmd(mpn_filter: Option<&str>) -> Result<()> { // The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser, // DigiKey) live in the "adom-chip-fetcher" Chrome profile. Creating ANY // other profile loses every credential. This constant is the single-// source of truth — every scrape/refetch/pup-pdf-fetch path uses it.+// source of truth, every scrape/refetch/pup-pdf-fetch path uses it. const SCRAPE_PROFILE: &str = "adom-chip-fetcher"; const SCRAPE_SESSION: &str = "adom-chip-fetcher-scrape"; @@ -973,7 +1104,7 @@ fn scrape_cmd(op: ScrapeOp) -> Result<()> { require_planned(&mpn)?; let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)])); // ONE window: the scrape tab lives beside the dashboard in the- // adom-chip-fetcher window (native) — no separate scrape window.+ // adom-chip-fetcher window (native), no separate scrape window. pup::scrape_navigate(&url)?; println!("OK: scrape tab open at {url} ({})", pup::surface_label()); hints::print_hint(&format!("page is loading. Next: `adom-chip-fetcher scrape eval {mpn} '<JS>'` to inspect, or `adom-chip-fetcher scrape screenshot {mpn}`."));@@ -1012,32 +1143,45 @@ fn scrape_cmd(op: ScrapeOp) -> Result<()> { fn creds_cmd(op: CredsOp) -> Result<()> { match op { CredsOp::List => {- let creds = crate::creds::load()?;+ let v = crate::vault::list()?;+ if !v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!("ERROR: {}", v.get("error").and_then(|e| e.as_str()).unwrap_or("vault unreachable"));+ hints::print_hint(v.get("_hint").and_then(|h| h.as_str()).unwrap_or("open Adom Hydrogen on your desktop so the bridge is connected"));+ return Ok(());+ }+ let creds = v.get("credentials").and_then(|c| c.as_array()).cloned().unwrap_or_default(); if creds.is_empty() {- println!("no stored credentials");- println!();- hints::print_hint("save creds with: adom-chip-fetcher creds save <host> <username> <password>");- hints::print_hint("hosts: snapeda, ultralib, componentsearch, ti, st, microchip, google");+ println!("OK: no vendor logins in pup's vault yet");+ hints::print_hint("import them from your browser: adom-chip-fetcher logins --import");+ hints::print_hint("or sign in once: adom-chip-fetcher login <source> --user <u> --pass <p>"); } else {- println!("stored credentials ({}):", creds.len());+ println!("OK: {} vendor login(s) in pup's OS-keychain vault (passwords never shown):", creds.len()); for c in &creds {- let masked = if c.password.len() > 2 {- format!("{}…{}", &c.password[..1], &c.password[c.password.len()-1..])- } else { "***".into() };- println!(" {:<20} user={:<25} pass={}", c.host, c.username, masked);+ println!(+ " {:<34} user={}",+ c.get("host").and_then(|h| h.as_str()).unwrap_or("?"),+ c.get("username").and_then(|u| u.as_str()).unwrap_or("?")+ ); } } Ok(()) } CredsOp::Save { host, username, password } => {- crate::creds::set(&host, &username, &password)?;- println!("OK: saved credential for {host} (user={username})");- println!("stored at ~/.config/adom-chip-fetcher/credentials.json");+ let v = crate::vault::set(&host, &username, &password)?;+ if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!("OK: stored login for {host} (user={username}) in pup's OS-keychain vault");+ } else {+ println!("ERROR: {}", v.get("error").and_then(|e| e.as_str()).unwrap_or("vault unreachable"));+ } Ok(()) } CredsOp::Delete { host } => {- crate::creds::delete(&host)?;- println!("OK: deleted credential for {host}");+ let v = crate::vault::delete(&host)?;+ if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!("OK: removed login for {host} from pup's vault");+ } else {+ println!("ERROR: {}", v.get("error").and_then(|e| e.as_str()).unwrap_or("vault unreachable"));+ } Ok(()) } }@@ -1080,7 +1224,7 @@ fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> { if !pdf.is_file() { missing += 1; continue; } total += 1; - // Read first 80 bytes — enough for magic + diagnostic preview.+ // Read first 80 bytes, enough for magic + diagnostic preview. let mut head = [0u8; 80]; let read = match std::fs::File::open(&pdf) .and_then(|mut f| std::io::Read::read(&mut f, &mut head))@@ -1125,7 +1269,7 @@ fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> { _ => sourcing::Source::Manufacturer, }; let _ = sourcing::append_chain(&mpn, src_enum, "html_masquerade",- Some(&format!("PDF was actually HTML — first 80 bytes: {}", preview.chars().take(60).collect::<String>())));+ Some(&format!("PDF was actually HTML, first 80 bytes: {}", preview.chars().take(60).collect::<String>()))); } } }@@ -1173,7 +1317,7 @@ fn pup_pdf_fetch_cmd( ) -> Result<()> { let dir = library::root().join(mpn); if !dir.is_dir() {- anyhow::bail!("no chip directory at {} — heartbeat or import once first to create it", dir.display());+ anyhow::bail!("no chip directory at {}, heartbeat or import once first to create it", dir.display()); } let source_enum = match source.to_ascii_lowercase().as_str() {@@ -1302,15 +1446,15 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u return Ok(()); } - // Real refetch — open URL, watch downloads, import.+ // Real refetch, open URL, watch downloads, import. let _ = hints::heartbeat(mpn, &format!("refetch {format} from {source}: opening pup")); let body = serde_json::json!({ "sessionId": "adom-chip-fetcher-scrape", "profile": "adom-chip-fetcher", "url": url, }).to_string();- let out = std::process::Command::new("adom-desktop")- .args(["browser_open_window", &body])+ let out = crate::bridge::command()+ .args(["pup_open_window", &body]) .output()?; if !out.status.success() { let _ = sourcing::append_chain(mpn, source_enum, "refetch_failed",@@ -1318,7 +1462,7 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u anyhow::bail!("could not open pup window: {}", String::from_utf8_lossy(&out.stderr)); } - println!("watching ~/Downloads for {timeout}s — click the source's download button to land the file");+ println!("watching ~/Downloads for {timeout}s, click the source's download button to land the file"); let glob = match format { "kicad_sym" | "kicad_mod" | "eagle" | "all" => "*.zip", "step" => "*.step",@@ -1330,7 +1474,7 @@ fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u "glob": glob, "timeoutMs": timeout * 1000, }).to_string();- let watch_out = std::process::Command::new("adom-desktop")+ let watch_out = crate::bridge::command() .args(["desktop_watch_files", &watch_body]) .output()?; if !watch_out.status.success() {@@ -1364,7 +1508,7 @@ fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> { let enc = mpn.replace(' ', "%20"); Ok(match source { // The wiki is pulled via its API (sources::wiki), not by opening a vendor- // page in pup — this is just the human search URL for completeness.+ // page in pup, this is just the human search URL for completeness. sourcing::Source::AdomWiki => format!("https://wiki.adom.inc/search?q={enc}"), sourcing::Source::Manufacturer => { sourcing::guess_mfr(mpn)@@ -1383,12 +1527,12 @@ fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> { /// Validate driver. Resolves MPN → chip directory under library/<MPN>/, /// runs the ds2sf → concur loop, prints the outcome. Returns non-zero /// only on hard error or when the loop ends in NeedsHuman /-/// Ds2sfUnrecoverable — so a CI-style caller can `&& adom-chip-fetcher+/// Ds2sfUnrecoverable, so a CI-style caller can `&& adom-chip-fetcher /// validate <MPN>` and trust the exit code. fn validate_cmd(mpn: &str) -> Result<()> { let dir = library::root().join(mpn); if !dir.is_dir() {- anyhow::bail!("no chip directory at {} — run adom-chip-fetcher first", dir.display());+ anyhow::bail!("no chip directory at {}, run adom-chip-fetcher first", dir.display()); } let outcome = validate::validate_chip_dir(&dir)?; validate::print_outcome(&outcome);@@ -1408,14 +1552,14 @@ fn validate_cmd(mpn: &str) -> Result<()> { /// opens their canonical product/search page in the adom-chip-fetcher-scrape /// pup session. Records the attempt on info.json.fetched_via_chain so /// next-source knows manufacturer has been tried. AI must run this-/// BEFORE any aggregator — that's the rule, now enforced.+/// BEFORE any aggregator, that's the rule, now enforced. fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> { let guess = sourcing::guess_mfr(mpn); match guess { Some(g) => { println!("OK: detected manufacturer {} for {mpn}", g.manufacturer); println!("URL: {}", g.search_url);- // Record the attempt as a navigation event (no outcome yet — the+ // Record the attempt as a navigation event (no outcome yet, the // AI fills in the outcome when it heartbeats success/empty). let _ = sourcing::append_chain(mpn, sourcing::Source::Manufacturer, "attempted",@@ -1426,7 +1570,7 @@ fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> { // available so ST/TI/Nordic logins + trust apply. match pup::open_site(SESSION, PROFILE, &g.search_url) { Ok(_) => println!("OK: opened {} in the adom-chip-fetcher window ({})", g.search_url, pup::surface_label()),- Err(e) => hints::print_hint(&format!("could not auto-open ({e}) — open the URL above manually")),+ Err(e) => hints::print_hint(&format!("could not auto-open ({e}), open the URL above manually")), } } hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `adom-chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates."));@@ -1450,7 +1594,7 @@ fn next_source_cmd(mpn: &str) -> Result<()> { println!("Sourcing chain so far for {mpn} ({} entries):", chain.len()); for e in &chain { let note = e.note.as_deref().unwrap_or("");- println!(" {} → {} {}", e.source, e.outcome, if note.is_empty() { "" } else { "—" });+ println!(" {} → {} {}", e.source, e.outcome, if note.is_empty() { "" } else { "," }); if !note.is_empty() { println!(" {note}"); } } println!();@@ -1460,14 +1604,14 @@ fn next_source_cmd(mpn: &str) -> Result<()> { if matches!(s, sourcing::Source::Manufacturer) { hints::print_hint(&format!("`adom-chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup.")); } else if matches!(s, sourcing::Source::Lcsc) {- hints::print_hint(&format!("LCSC is the LAST step — only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC."));+ hints::print_hint(&format!("LCSC is the LAST step, only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC.")); } else { hints::print_hint(&format!("heartbeat `adom-chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug())); } } None => { println!("NEXT: ladder fully exhausted for {mpn}. No more steps to try.");- hints::print_hint("if the chip is still incomplete, surface 'no CAD available anywhere' to the user — don't claim done.");+ hints::print_hint("if the chip is still incomplete, surface 'no CAD available anywhere' to the user, don't claim done."); } } Ok(())@@ -1476,7 +1620,7 @@ fn next_source_cmd(mpn: &str) -> Result<()> { /// Hard gate. Walks the library, computes each chip's completeness against /// the sourcing-ladder rule, prints HINT lines per missing artifact, and /// returns non-zero exit when any card is incomplete. The AI cannot honestly-/// claim "done" without this returning zero — that's the point.+/// claim "done" without this returning zero, that's the point. fn backfill_provenance_cmd(mpn_filter: Option<&str>) -> Result<()> { let lib = inventory::scan()?; let mut fixed = 0usize;@@ -1497,7 +1641,7 @@ fn backfill_provenance_cmd(mpn_filter: Option<&str>) -> Result<()> { println!("backfill: recovered provenance on {fixed} chip(s)."); if !still_unknown.is_empty() { println!();- println!("Source genuinely UNRECORDED (no signal to derive from — refetch to capture):");+ println!("Source genuinely UNRECORDED (no signal to derive from, refetch to capture):"); for (mpn, files) in &still_unknown { println!(" {mpn}: {}", files.join(", ")); }@@ -1529,14 +1673,14 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { if !c.has_step { missing.push("STEP".into()); } if !c.has_sym { missing.push("KiCad .kicad_sym".into()); } if !c.has_mod { missing.push("KiCad .kicad_mod".into()); }- // PERMANENT GUARANTEE 1 — the datasheet must be REAL (multi-page + pinout),+ // PERMANENT GUARANTEE 1, the datasheet must be REAL (multi-page + pinout), // not a 1-page denial page that silently breaks ds2sf. if c.has_pdf { if let Err(reason) = crate::import::datasheet_ready(&pdf) {- missing.push(format!("GOOD datasheet — {reason}"));+ missing.push(format!("GOOD datasheet, {reason}")); } }- // PERMANENT GUARANTEE 2 — ds2sf (the most authoritative source: the+ // PERMANENT GUARANTEE 2, ds2sf (the most authoritative source: the // manufacturer datasheet extraction) must have RUN and SUCCEEDED. let ds_ok = std::fs::read_to_string(dir.join(format!("{}-extraction.result.json", c.mpn))).ok() .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())@@ -1555,14 +1699,14 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { } } // A pin-1 corner MISMATCH (signed chip corner != the footprint's pin-1- // corner) means the 3D model is misaligned to the footprint — a real+ // corner) means the 3D model is misaligned to the footprint, a real // defect, so flag it in BOTH modes, not just strict. if let (true, Some(cc), Some(fp)) = (c.pin1_signed, c.pin1_corner.as_deref(), c.fp_pin1_corner.as_deref()) { if cc != fp { missing.push(format!("pin-1 MISMATCH: chip {cc} vs footprint {fp} (re-align in chipsmith)")); } }- // PERMANENT GUARANTEE 3 — every file we ship must know where it came+ // PERMANENT GUARANTEE 3, every file we ship must know where it came // from. A file present on disk with no recorded source is a lazy fetch. // `backfill-provenance` recovers what it can; whatever's left is real. let unprov = crate::import::unprovenanced_files(&dir, &c.mpn);@@ -1581,7 +1725,7 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { println!(); if !incomplete.is_empty() {- println!("Incomplete cards — DO NOT claim done while these exist:");+ println!("Incomplete cards, DO NOT claim done while these exist:"); for (mpn, missing) in &incomplete { println!(" {mpn}: missing {}", missing.join(", ")); }@@ -1592,7 +1736,7 @@ fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> { } } println!();- hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.",+ hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work, it returns 0 only when every card is complete.", if strict { " --strict" } else { "" })); anyhow::bail!("gate FAILED: {} of {total} cards incomplete", incomplete.len()); }@@ -1605,13 +1749,13 @@ fn sources_cmd() -> Result<()> { // Single source of truth for the sourcing ladder. Both this command and // hints::sourcing_ladder_lines() read from here, so playbook + binary stay // in sync. AI consumers can call `adom-chip-fetcher sources` and parse the- // ordered list — no need to re-read the playbook prose.+ // ordered list, no need to re-read the playbook prose. println!("Sourcing-ladder priority (always go in order, never skip):\n"); for (i, (name, note)) in hints::SOURCE_LADDER.iter().enumerate() { println!(" {}. {:<14} {}", i + 1, name, note); } println!();- hints::print_hint("LCSC is LAST RESORT — avoid if possible. Surface 'no manufacturer CAD' to user before reaching for LCSC.");+ hints::print_hint("LCSC is LAST RESORT, avoid if possible. Surface 'no manufacturer CAD' to user before reaching for LCSC."); hints::print_hint("Stock data (stock.json) comes from Mouser regardless of where CAD/PDFs land."); Ok(()) }@@ -1619,7 +1763,7 @@ fn sources_cmd() -> Result<()> { fn hint_cmd() -> Result<()> { match hints::last() { None => {- hints::print_hint("no heartbeat yet — kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`");+ hints::print_hint("no heartbeat yet, kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"); hints::print_hint("then `adom-chip-fetcher selfcheck` to verify dashboard + pup session."); } Some(hb) => {@@ -1627,13 +1771,13 @@ fn hint_cmd() -> Result<()> { println!("last heartbeat: {} → {:?} ({}s ago)", hb.mpn, hb.stage, age); if age > 60 { hints::print_hint(&format!(- "stale heartbeat — refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`",+ "stale heartbeat, refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`", hb.mpn )); } else { // If the stage is one of our canonical post-action labels, suggest // the next concrete command. Otherwise fall back to a generic prompt- // so this is never silent — the AI always gets a next step.+ // so this is never silent, the AI always gets a next step. let canonical = matches!(hb.stage.as_str(), "fetched" | "imported" | "pulled" | "listed" | "status" | "login"); if canonical {@@ -1656,18 +1800,12 @@ fn hint_cmd() -> Result<()> { } fn app_cmd(port: u16) -> Result<()> {- use std::sync::mpsc;- let (tx, rx) = mpsc::channel::<()>();- let handle = std::thread::spawn(move || {- // Signal "ready" once we're about to bind. The bind itself is fast.- let _ = tx.send(());- let _ = serve::run(port);- });- let _ = rx.recv();- std::thread::sleep(std::time::Duration::from_millis(300));- serve::open_in_hydrogen(port)?;- let _ = handle.join();- Ok(())+ // The dashboard is the user's home base and pup is their surface. Start+ // (or reuse) the server in the background, then open / reuse the+ // maximized pup window. Never a webview, never a second window.+ let _ = port;+ hints::set_dashboard_surface(hints::DashboardSurface::Pup)?;+ hints::dashboard_ensure(false) } fn install_cmd() -> Result<()> {@@ -1680,7 +1818,7 @@ fn install_cmd() -> Result<()> { println!(" {} bytes, {} lines", SKILL_MD.len(), SKILL_MD.lines().count()); // Deploy playbooks/ alongside SKILL.md. Each playbook is a focused deep-dive Claude- // reads on demand — keeping SKILL.md lean enough to survive context-window compaction.+ // reads on demand, keeping SKILL.md lean enough to survive context-window compaction. let playbooks_dir = skill_dir.join("playbooks"); std::fs::create_dir_all(&playbooks_dir)?; let mut total_bytes: usize = 0;@@ -1717,7 +1855,7 @@ fn pull_cmd(remote_path: &str, mpn: Option<&str>, no_validate: bool, discovery_s for f in &report.files_added { println!(" + {f}"); } if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) { let _ = hints::heartbeat(m, "imported");- // Validate-then-thumbnail order — ds2sf may patch info.json.package+ // Validate-then-thumbnail order, ds2sf may patch info.json.package // (e.g. VQFN-32 → VQFN-40), and thumbnails should reflect the // corrected metadata, not the pre-patch one. if !no_validate {@@ -1753,20 +1891,20 @@ fn import_cmd(path: &std::path::Path, mpn: Option<&str>, no_validate: bool, disc } /// Drive the validation pipeline (ds2sf + concur with autonomous-repair-/// loop) after a fresh file lands. Best-effort — failures here surface as+/// loop) after a fresh file lands. Best-effort, failures here surface as /// WARN; they don't unwind the import. The `--no-validate` flag bypasses /// this for cases where the user is dropping individual files mid-fetch. fn run_post_import_validate(dir: &std::path::Path, mpn: &str) { println!();- println!("running validation (ds2sf + concur) — gold-dataset gate");+ println!("running validation (ds2sf + concur), gold-dataset gate"); match validate::validate_chip_dir(dir) { Ok(outcome) => validate::print_outcome(&outcome),- Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),+ Err(e) => println!("WARN: validate failed for {mpn}: {e}, re-run with `adom-chip-fetcher validate {mpn}`"), } } /// Best-effort thumbnail re-render for one MPN immediately after import.-/// Errors are swallowed (printed as WARN) — the import already succeeded;+/// Errors are swallowed (printed as WARN), the import already succeeded; /// thumbnails are a UX nicety, not a correctness gate. fn auto_render_thumbnails(mpn: &str) { match thumbnails::generate_for(mpn, false) {@@ -1791,11 +1929,11 @@ fn wiki_fetch_cmd(mpn: &str) -> Result<()> { println!(" + {f}"); } let _ = thumbnails::generate_for(mpn, true);- println!("\nReused from the Adom Wiki — no external fetch needed.");+ println!("\nReused from the Adom Wiki, no external fetch needed."); } None => { println!("Adom Wiki: no published bundle for {mpn}.");- hints::print_hint(&format!("not on the wiki — run `adom-chip-fetcher fetch {mpn}` to walk the vendor ladder (manufacturer → SnapMagic → …)."));+ hints::print_hint(&format!("not on the wiki, run `adom-chip-fetcher fetch {mpn}` to walk the vendor ladder (manufacturer → SnapMagic → …).")); } } Ok(())@@ -1803,12 +1941,22 @@ fn wiki_fetch_cmd(mpn: &str) -> Result<()> { fn fetch(mpn: &str) -> Result<()> { library::ensure_dir(mpn)?;+ // The whole lookup, start to finish, shows an indeterminate taskbar bar on+ // the dashboard window (issue #3). The guard clears it on every exit path.+ struct BarGuard;+ impl Drop for BarGuard {+ fn drop(&mut self) {+ crate::taskbar::clear();+ }+ }+ let _bar = BarGuard;+ crate::taskbar::start_indeterminate(&format!("{mpn}: starting lookup")); - // Ladder step 0 — the Adom Wiki. If this part is already published with a+ // Ladder step 0, the Adom Wiki. If this part is already published with a // complete bundle, reuse it (free, fast, Adom-consistent) and skip the // external vendors entirely. Reads only; no token. On no match (or only a // metadata-only page with no CAD files) we fall straight through to the- // vendor ladder below — the existing ladder is untouched.+ // vendor ladder below, the existing ladder is untouched. match crate::sources::wiki::try_fetch(mpn) { Ok(Some(pull)) => { println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);@@ -1817,15 +1965,15 @@ fn fetch(mpn: &str) -> Result<()> { } let _ = hints::heartbeat(mpn, "imported (Adom Wiki)"); let _ = thumbnails::generate_for(mpn, true);- println!("\nReused from the Adom Wiki — no external fetch needed. Run `adom-chip-fetcher gate --mpn {mpn}` to confirm completeness.");+ println!("\nReused from the Adom Wiki, no external fetch needed. Run `adom-chip-fetcher gate --mpn {mpn}` to confirm completeness."); return Ok(()); }- Ok(None) => println!("Adom Wiki: no published bundle for {mpn} — falling through to the vendor ladder."),- Err(e) => println!("WARN: Adom Wiki lookup failed ({e}) — falling through to the vendor ladder."),+ Ok(None) => println!("Adom Wiki: no published bundle for {mpn}, falling through to the vendor ladder."),+ Err(e) => println!("WARN: Adom Wiki lookup failed ({e}), falling through to the vendor ladder."), } // Rail: dashboard MUST be visible before a fetch starts. Auto-open if not.- // Best-effort — a failure here doesn't block the fetch (user might be+ // Best-effort, a failure here doesn't block the fetch (user might be // running headless), but they'll see a WARN. if let Err(e) = hints::dashboard_ensure(false) { println!("WARN: dashboard_ensure failed: {e}");@@ -1842,7 +1990,7 @@ fn fetch(mpn: &str) -> Result<()> { println!("page title: {title}"); println!("\nstep 1: on the TI page, click \"CAD/CAE Symbols & Footprints\" → opens UL with the part loaded");- println!("step 2: in UL — pick variant, check STEP + KiCAD v6+, set units to mm, click Submit");+ println!("step 2: in UL, pick variant, check STEP + KiCAD v6+, set units to mm, click Submit"); println!("step 3: adom-chip-fetcher will auto-pull the download (up to 5 min wait)"); println!(); @@ -1871,15 +2019,15 @@ fn fetch(mpn: &str) -> Result<()> { // Validate FIRST, thumbnails SECOND. ds2sf may patch info.json (e.g. // package family correction VQFN-32 → VQFN-40), and the thumbnail- // render reads info.json for orientation hints + label fields — so+ // render reads info.json for orientation hints + label fields, so // thumbnails should run against the patched metadata, not the // pre-patch one. Closes gap #3 from the integration design doc. println!();- println!("running validation (ds2sf + concur) — this is the agent-to-agent gate downstream tools rely on");+ println!("running validation (ds2sf + concur), this is the agent-to-agent gate downstream tools rely on"); let dir = library::root().join(mpn); match validate::validate_chip_dir(&dir) { Ok(outcome) => validate::print_outcome(&outcome),- Err(e) => println!("WARN: validate failed: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),+ Err(e) => println!("WARN: validate failed: {e}, re-run with `adom-chip-fetcher validate {mpn}`"), } auto_render_thumbnails(mpn);@@ -1889,7 +2037,7 @@ fn fetch(mpn: &str) -> Result<()> { /// Block until a fresh `ul_*.zip` lands in the user's Windows Downloads folder, /// then pull it to the container's `incoming/`. Uses adom-desktop v1.5+'s-/// native `desktop_watch_files` — no shell, no PowerShell, no user-approval+/// native `desktop_watch_files`, no shell, no PowerShell, no user-approval /// dialog. Times out after 5 minutes. fn wait_for_download() -> Result<std::path::PathBuf> { println!("watching desktop ~/Downloads for ul_*.zip (up to 5 min)...");@@ -1928,19 +2076,9 @@ const SOURCES: &[(&str, &str, &str)] = &[ ("componentsearch", "Component Search Engine (samacsys)", "https://componentsearchengine.com/account/login"), ]; -fn login(source: &str) -> Result<()> {- if source == "all" {- println!("opening login pages for all sources as tabs in profile '{PROFILE}'");- for (name, _, url) in SOURCES {- println!(" → {name}: {url}");- // First call ensures the window exists; subsequent calls add tabs- let _ = pup::open_tab(SESSION, url);- // Fall back to open_window if the session doesn't exist yet- pup::open_window(SESSION, PROFILE, url)?;- }- println!("\nlog in to each, then 'Stay signed in' / Smart Lock will keep cookies for ~30 days.");- return Ok(());- }+/// Sign in to one source from pup's vault. Public so the dashboard's+/// `POST /api/login` can drive the same path.+pub fn login_source(source: &str, user: Option<&str>, pass: Option<&str>) -> Result<()> { if source == "list" { println!("known sources:"); for (name, desc, url) in SOURCES {@@ -1949,21 +2087,68 @@ fn login(source: &str) -> Result<()> { } return Ok(()); }+ if source == "all" {+ for (name, _, _) in SOURCES {+ if *name == "google" { continue; }+ println!("== {name}");+ if let Err(e) = login_source(name, None, None) {+ println!("WARN: {name}: {e}");+ }+ std::thread::sleep(std::time::Duration::from_secs(2));+ }+ return Ok(());+ } let entry = SOURCES.iter().find(|(n, _, _)| { *n == source || (source == "ultralibrarian" && *n == "ultralib") || (source == "snapmagic" && *n == "snapeda") || (source == "samacsys" && *n == "componentsearch")+ || (source == "cse" && *n == "componentsearch") });- let url = match entry {- Some((_, _, u)) => *u,+ let (alias, url) = match entry {+ Some((n, _, u)) => (*n, *u), None => anyhow::bail!(- "unknown source '{source}' — run `adom-chip-fetcher login list` to see available sources, or `adom-chip-fetcher login all` to open every source"+ "unknown source '{source}'.\n Hint: `adom-chip-fetcher login list` shows the catalog; `login all` does every source" ), };- println!("opening {url} for login — cookies will persist in profile '{PROFILE}'");- pup::open_window(SESSION, PROFILE, url)?;- hints::print_next_after("login", None);+ let host = url.split('/').nth(2).unwrap_or("");+ let apex = host.splitn(2, '.').last().unwrap_or(host);+ let host_pattern = format!("*.{apex}");+ println!("opening {url} in the chip-fetcher pup window (profile '{PROFILE}')");+ hints::dashboard_ensure(false).ok();+ if pup::session_live(SESSION) {+ pup::open_tab(SESSION, url)?;+ } else {+ pup::open_window(SESSION, PROFILE, url)?;+ }+ std::thread::sleep(std::time::Duration::from_secs(3));+ let reason = format!("{alias} sign-in page {host} is the known login URL from chip-fetcher's source catalog; the user asked chip-fetcher to log in here");+ let r = crate::vault::login(SESSION, user, pass, Some(&host_pattern), &reason)?;+ let code = r.get("errorCode").and_then(|c| c.as_str()).unwrap_or("");+ if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) || r.get("submitted").and_then(|b| b.as_bool()).unwrap_or(false) {+ println!(+ "OK: signed in to {alias} as {} (credential from {})",+ r.get("filledUsername").and_then(|u| u.as_str()).unwrap_or("?"),+ r.get("credentialSource").and_then(|u| u.as_str()).unwrap_or("vault")+ );+ hints::print_next_after("login", None);+ return Ok(());+ }+ match code {+ "no_credential" => {+ println!("ERROR: pup's vault has no login for {host_pattern}.");+ hints::print_hint(&format!("adom-chip-fetcher login {alias} --user <email> --pass <password> (pup stores it in the OS keychain)"));+ hints::print_hint("or import it from your browser: adom-chip-fetcher logins --import");+ }+ "no_login_form" => {+ println!("ERROR: no username/password form on that page (probably an SSO page, or already signed in).");+ hints::print_hint("if the page offers 'Continue with Google', sign in to Google once: adom-chip-fetcher login google");+ }+ _ => {+ println!("ERROR: {}", r.get("error").and_then(|e| e.as_str()).unwrap_or("pup_login failed"));+ hints::print_hint("take a look: adom-bridge pup_screenshot '{\"sessionId\":\"adom-chip-fetcher\"}'");+ }+ } Ok(()) } @@ -1992,3 +2177,152 @@ fn status(mpn: &str) -> Result<()> { fn mark(present: bool) -> &'static str { if present { "✓" } else { "·" } }+++// ---- dock / doctor / ui / settings (docs/dashboard-api-contract.md) ----++fn health_cmd(port: u16) -> Result<()> {+ match crate::dock::http_get(port, "/health", std::time::Duration::from_millis(1500)) {+ Some(body) => {+ println!("OK: {}", body.trim());+ Ok(())+ }+ None => {+ println!("ERROR: no dashboard answering on port {port}");+ hints::print_hint(&format!("start it: adom-chip-fetcher serve --port {port} --print-url"));+ std::process::exit(1)+ }+ }+}++/// First-run audit. One line per component, a Hint per problem, exit 1 when+/// anything is red or yellow so a script can gate on it.+fn doctor_cmd(fix: bool, port: u16) -> Result<()> {+ let f = crate::dock::build_facets();+ let mut bad = 0;+ println!("adom-chip-fetcher {} doctor", crate::dock::VERSION);+ let order = ["dashboard", "skill", "tools", "bridge", "library"];+ for key in order {+ let Some(led) = f.leds.get(key) else { continue };+ let state = led.get("state").and_then(|s| s.as_str()).unwrap_or("off");+ let mark = match state { "ok" => "OK ", "warn" => "WARN", _ => "FAIL" };+ if state != "ok" { bad += 1; }+ let detail = if key == "dashboard" {+ match crate::dock::running_version(port) {+ Some(v) => format!("v{v} serving on {}", crate::dock::direct_url(port)),+ None => { bad += 1; "not running".to_string() }+ }+ } else {+ led.get("detail").and_then(|d| d.as_str()).unwrap_or("").to_string()+ };+ println!("{mark} {:<14} {detail}", led.get("label").and_then(|l| l.as_str()).unwrap_or(key));+ if state != "ok" || detail == "not running" {+ let hint = if key == "dashboard" && detail == "not running" {+ format!("adom-chip-fetcher serve --port {port} --print-url")+ } else {+ led.get("_hint").and_then(|h| h.as_str()).unwrap_or("").to_string()+ };+ if !hint.is_empty() { hints::print_hint(&hint); }+ }+ }+ // pup: the scrape/dashboard profile is a pup concern; report whether pup answers at all.+ if crate::bridge::available() && !crate::bridge::connected_targets().is_empty() {+ let v = crate::bridge::call("pup_status", &serde_json::json!({}))?;+ if crate::bridge::succeeded(&v) {+ println!("OK {:<14} pup answers on the desktop", "pup");+ } else {+ bad += 1;+ println!("WARN {:<14} {}", "pup", crate::bridge::error_text(&v).lines().next().unwrap_or("not answering"));+ hints::print_hint("install pup on the desktop: https://wiki.adom.inc/adom/pup-bridge");+ }+ }+ if fix {+ if !f.missing_tools.is_empty() {+ println!("\n== installing the missing sister tools via the wiki");+ let st = std::process::Command::new("sh")+ .args(["-c", "export PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/adom-chip-fetcher"])+ .status()?;+ println!("{}", if st.success() { "OK: wiki install finished" } else { "ERROR: wiki install failed" });+ }+ let home = std::env::var("HOME").unwrap_or_default();+ if !std::path::Path::new(&home).join(".claude/skills/adom-chip-fetcher/SKILL.md").is_file() {+ println!("\n== installing the AI skill");+ install_cmd()?;+ }+ if crate::dock::running_version(port).is_none() {+ println!("\n== starting the dashboard");+ serve::run(port, true, false)?;+ }+ println!("\nre-run `adom-chip-fetcher doctor` to confirm.");+ return Ok(());+ }+ if bad > 0 {+ println!("\n{bad} component(s) need attention. `adom-chip-fetcher doctor --fix` installs what is missing.");+ std::process::exit(1);+ }+ println!("\nall green. Say to your AI: \"{}\"", crate::dock::say_phrases().first().cloned().unwrap_or_else(|| "fetch STM32F103C8T6 with chip fetcher".into()));+ Ok(())+}++fn ui_cmd(op: UiOp, port: u16) -> Result<()> {+ let t = std::time::Duration::from_millis(2500);+ let post = |path: &str, body: serde_json::Value| -> Result<()> {+ match crate::dock::http_post(port, path, &body.to_string(), t) {+ Some(r) if r.contains("\"ok\":true") => { println!("OK: {path} {body}"); Ok(()) }+ Some(r) => { println!("ERROR: {path}: {}", r.trim()); std::process::exit(1) }+ None => {+ println!("ERROR: no dashboard answering on port {port}");+ hints::print_hint(&format!("start it: adom-chip-fetcher serve --port {port} --print-url"));+ std::process::exit(1)+ }+ }+ };+ match op {+ UiOp::Toast { message, kind } => post("/ui/toast", serde_json::json!({"message": message, "kind": kind})),+ UiOp::Click { id } => post("/ui/command", serde_json::json!({"cmd": "click", "id": id})),+ UiOp::Select { mpn } => post("/ui/command", serde_json::json!({"cmd": "select", "mpn": mpn})),+ UiOp::View { view } => post("/ui/command", serde_json::json!({"cmd": "view", "view": view})),+ UiOp::Settings { state } => post("/ui/command", serde_json::json!({"cmd": if state == "open" { "open_settings" } else { "close_settings" }})),+ UiOp::Zoom { mpn } => post("/ui/command", serde_json::json!({"cmd": "zoom", "mpn": mpn})),+ UiOp::Unzoom => post("/ui/command", serde_json::json!({"cmd": "unzoom"})),+ UiOp::Reload => post("/ui/command", serde_json::json!({"cmd": "reload"})),+ UiOp::State => {+ match crate::dock::http_get(port, "/state", t) {+ Some(b) => { println!("{b}"); Ok(()) }+ None => { println!("ERROR: no dashboard answering on port {port}"); std::process::exit(1) }+ }+ }+ UiOp::Console { limit } => {+ match crate::dock::http_get(port, &format!("/console?limit={limit}"), t) {+ Some(b) => {+ let v: serde_json::Value = serde_json::from_str(&b).unwrap_or_default();+ for it in v.get("items").and_then(|i| i.as_array()).cloned().unwrap_or_default() {+ println!("{} [{}] {}", it.get("t").and_then(|t| t.as_u64()).unwrap_or(0), it.get("level").and_then(|l| l.as_str()).unwrap_or(""), it.get("msg").and_then(|m| m.as_str()).unwrap_or(""));+ }+ Ok(())+ }+ None => { println!("ERROR: no dashboard answering on port {port}"); std::process::exit(1) }+ }+ }+ }+}++fn settings_cmd(op: SettingsOp) -> Result<()> {+ match op {+ SettingsOp::Get => {+ let d = crate::settings::defaults();+ for (k, v) in crate::settings::load() {+ println!("{k:<24} = {v} (default {})", d.get(&k).map(|x| x.to_string()).unwrap_or_default());+ }+ println!("file: {}", crate::settings::path().display());+ Ok(())+ }+ SettingsOp::Set { key, value } => {+ match crate::settings::merge(&serde_json::json!({ key.clone(): value })) {+ Ok(m) => { println!("OK: {key} = {}", m.get(&key).map(|v| v.to_string()).unwrap_or_default()); Ok(()) }+ Err(e) => { println!("ERROR: {e}"); std::process::exit(1) }+ }+ }+ SettingsOp::Reset => { crate::settings::reset()?; println!("OK: settings restored to defaults"); Ok(()) }+ }+}
src/deliver.rs+12−12@@ -1,7 +1,7 @@-//! "Send to" delivery for a fetched chip — push its CAD straight to the user's+//! "Send to" delivery for a fetched chip, push its CAD straight to the user's //! desktop EDA tools via the adom-desktop bridge. KiCad gets the native //! `.kicad_sym` + `.kicad_mod`; Fusion 360 gets a true EAGLE `.lbr` (symbol +-//! footprint) built by adom-lbr. Altium / OrCAD have no bridge yet — the `.lbr`+//! footprint) built by adom-lbr. Altium / OrCAD have no bridge yet, the `.lbr` //! is built, but we invite the user to build the bridge and contribute it. use serde_json::{json, Value};@@ -9,7 +9,7 @@ use std::path::Path; use std::process::Command; fn ad(verb: &str, payload: Value) -> Result<Value, String> {- let mut cmd = Command::new("adom-desktop");+ let mut cmd = crate::bridge::command(); // When the relay has >1 client (e.g. laptop + a cloud VM), a bare verb is // ambiguous. Honor ADOM_DESKTOP_TARGET to pin the host; single-client setups // need nothing.@@ -21,7 +21,7 @@ fn ad(verb: &str, payload: Value) -> Result<Value, String> { let out = cmd .args([verb, &payload.to_string()]) .output()- .map_err(|e| format!("adom-desktop unreachable: {e}"))?;+ .map_err(|e| format!("adom-bridge unreachable: {e}"))?; let txt = String::from_utf8_lossy(&out.stdout).to_string(); Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt }))) }@@ -37,7 +37,7 @@ fn looks_offline(v: &Value) -> bool { /// downloadable file with tool-accurate import guidance. Altium imports EAGLE /// libraries natively (its Import Wizard); OrCAD has no native EAGLE path, so we /// say so honestly. Either way a desktop *auto-import bridge* would streamline-/// it — that's the contribution CTA.+/// it, that's the contribution CTA. fn library_export(tool: &str, mpn: &str, lbr_name: &str) -> Value { let asset_url = format!("/asset/{mpn}/{lbr_name}"); let (works, import_hint) = if tool == "Altium" {@@ -56,9 +56,9 @@ fn library_export(tool: &str, mpn: &str, lbr_name: &str) -> Value { "message": format!( "Built a real EAGLE .lbr for {tool} (symbol + footprint, lint-passed). {}", if works {- "Download it below and import it — Altium reads EAGLE libraries natively. A one-click Adom Desktop bridge would auto-import it; want to build that? Contribute on the wiki."+ "Download it below and import it, Altium reads EAGLE libraries natively. A one-click Adom Desktop bridge would auto-import it; want to build that? Contribute on the wiki." } else {- "Download it below. A native OrCAD exporter (and an Adom Desktop bridge to auto-import) aren't built yet — want OrCAD in the Adom ecosystem? Contribute on the wiki."+ "Download it below. A native OrCAD exporter (and an Adom Desktop bridge to auto-import) aren't built yet, want OrCAD in the Adom ecosystem? Contribute on the wiki." } ) })@@ -151,7 +151,7 @@ fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") }) } -/// Standard base64 (no external crate) — for sending file content through the bridge.+/// Standard base64 (no external crate), for sending file content through the bridge. fn b64(data: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity((data.len() + 2) / 3 * 4);@@ -168,7 +168,7 @@ fn b64(data: &[u8]) -> String { } /// True if a single bridge response confirms real success (the bridge's `success`-/// flag, or its confirmation text — handles AD wrapping the result in `output`).+/// flag, or its confirmation text, handles AD wrapping the result in `output`). fn bridge_ok(r: &Result<Value, String>) -> bool { match r { Err(_) => false,@@ -182,13 +182,13 @@ fn bridge_ok(r: &Result<Value, String>) -> bool { } } -/// A descriptive paste-into-Claude prompt — always offered so the user can finish+/// A descriptive paste-into-Claude prompt, always offered so the user can finish /// the delivery in their own AI thread if anything is off. fn claude_prompt_chip(mpn: &str, dir: &Path) -> String { format!( "I fetched the KiCad CAD for chip \"{mpn}\" (files in `{}`: {mpn}.kicad_sym and {mpn}.kicad_mod). Please \ install them into my KiCad \"Adom\" symbol + footprint libraries and open the symbol in the KiCad Symbol \- Editor so I can see it — use the adom-desktop KiCad bridge (`kicad_install_symbol` with openEditor:true, \+ Editor so I can see it, use the adom-desktop KiCad bridge (`kicad_install_symbol` with openEditor:true, \ and `kicad_install_footprint`), then confirm they actually landed.", dir.display() )@@ -206,7 +206,7 @@ fn summarize_kicad_chip(mpn: &str, dir: &Path, results: Vec<(&str, Result<Value, }; if offline { return json!({ "ok": false, "target": "KiCad", "offline": true, "claudePrompt": prompt,- "message": "Adom Desktop isn't connected. Open it on your laptop and retry — or paste the prompt into Claude." });+ "message": "Adom Desktop isn't connected. Open it on your laptop and retry, or paste the prompt into Claude." }); } } let delivered: Vec<&str> = results.iter().filter(|(_, r)| bridge_ok(r)).map(|(w, _)| *w).collect();
src/dock.rsadded+541@@ -0,0 +1,541 @@+//! The Hydrogen Dock Bar contract for chip-fetcher.+//!+//! Reference: the Dock Bar guide on adom/hydrogen (docs/dockbar.md) and+//! adom/kicad-bridge's dashboard, which was verified green end to end on+//! 2026-08-23. Everything here exists because one of the first four bridges+//! shipped it wrong:+//!+//! * `serve --print-url` must print the DIRECT `http://127.0.0.1:<port>/`+//! URL (never the `/proxy/<port>/` form, never the name `localhost`), then+//! SELF-BACKGROUND and exit 0, or the dock's synchronous exec kills the+//! server at its timeout and the readiness probe finds nothing listening.+//! * serve must be IDEMPOTENT and VERSION-AWARE: an instance of ours already+//! on the port makes relaunch instant; an instance of a DIFFERENT version+//! is the thing to replace, matched by exact argv (a substring match once+//! killed the shell that invoked it).+//! * every `/api/*` response sends `Access-Control-Allow-Origin: *` and+//! answers `OPTIONS`, or the dock's cross-origin health probe is blocked+//! by the browser and the card LED reads "not answering" while curl says+//! 200.+//! * the dock sets `ADOM_DOCK_WEBVIEW` to the tab it opened for us; we take+//! that tab over with `adom-cli hydrogen webview open-or-refresh`.+//! * the `say` phrases in dockbar.json are how users learn to ASK for the+//! app; the dashboard carries the same list at the very top.++use serde_json::{json, Value};+use std::collections::VecDeque;+use std::io::{Read, Write};+use std::net::{SocketAddr, TcpStream};+use std::sync::Mutex;+use std::time::{Duration, Instant};++pub const APP: &str = "adom-chip-fetcher";+pub const VERSION: &str = env!("CARGO_PKG_VERSION");+pub const DOCKBAR_JSON: &str = include_str!("../dockbar.json");+pub const APP_TAG: &str = "app:adom/adom-chip-fetcher";+const HYDROGEN_CONTROL_PORT: u16 = 47084;++/// The sister CLIs `adom-wiki pkg install adom/adom-chip-fetcher` pulls in+/// (page.json `dependencies`). Each is a wiki package whose binary carries the+/// same name. chip-fetcher orchestrates them: symbol/footprint/3D viewers,+/// the datasheet and STEP converters, pin-1 sign-off, thumbnails, and the+/// distributor backends.+pub const SISTER_TOOLS: &[(&str, &str)] = &[+ ("adom-symbol", "symbol viewer + .kicad_sym export"),+ ("adom-footprint", "footprint viewer + .kicad_mod export"),+ ("adom-step", "3D STEP viewer"),+ ("adom-lbr", "Fusion 360 / EAGLE .lbr export"),+ ("adom-chipsmith", "pin-1 alignment + sign-off"),+ ("adom-chip-thumbnailer", "card thumbnails (3D iso, symbol, footprint)"),+ ("adom-concur", "symbol/footprint cross-check"),+ ("adom-ds2sf", "datasheet to symbol + footprint"),+ ("adom-sfconvert", "EDA format conversion"),+ ("adom-parts-search", "unified Mouser + DigiKey + JLCPCB search"),+ ("adom-mouser", "Mouser stock + pricing"),+ ("adom-digikey", "DigiKey stock + CAD links"),+ ("adom-jlcpcb", "JLCPCB stock"),+];++pub fn direct_url(port: u16) -> String {+ format!("http://127.0.0.1:{port}/")+}++// ---------------------------------------------------------------- loopback http++/// Minimal HTTP/1.0 request over loopback with a hard timeout. Returns the+/// body (headers stripped). std only: shelling to curl costs 20-50ms per+/// probe, which matters when the dock polls every 15s and budgets 2s.+fn http(port: u16, method: &str, path: &str, body: Option<&str>, timeout: Duration) -> Option<String> {+ let addr: SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;+ let mut s = TcpStream::connect_timeout(&addr, timeout).ok()?;+ s.set_read_timeout(Some(timeout)).ok()?;+ s.set_write_timeout(Some(timeout)).ok()?;+ let body = body.unwrap_or("");+ let req = format!(+ "{method} {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nUser-Agent: {APP}/{VERSION}\r\n\+ Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",+ body.len()+ );+ s.write_all(req.as_bytes()).ok()?;+ let mut raw = Vec::new();+ let _ = s.read_to_end(&mut raw);+ let text = String::from_utf8_lossy(&raw).to_string();+ let idx = text.find("\r\n\r\n")?;+ Some(text[idx + 4..].to_string())+}++pub fn http_get(port: u16, path: &str, timeout: Duration) -> Option<String> {+ http(port, "GET", path, None, timeout)+}++pub fn http_post(port: u16, path: &str, body: &str, timeout: Duration) -> Option<String> {+ http(port, "POST", path, Some(body), timeout)+}++// ------------------------------------------------------- idempotent serve++/// Version of OUR dashboard on `port`, or None when nothing (or something+/// that is not us) answers. Another service on the port is not a reason to+/// claim success.+pub fn running_version(port: u16) -> Option<String> {+ let body = http_get(port, "/health", Duration::from_millis(1500))?;+ let v: Value = serde_json::from_str(&body).ok()?;+ if v.get("ok").and_then(|b| b.as_bool()) == Some(true) && v.get("app").and_then(|a| a.as_str()) == Some(APP) {+ Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("?").to_string())+ } else {+ None+ }+}++/// Retire a stale instance of a different version. Kills only processes+/// whose argv is EXACTLY `[<...>/adom-chip-fetcher, serve, ...]` (or the+/// `chip-fetcher` alias), never a shell whose command text merely mentions+/// us. Returns true once the port is free.+pub fn retire_stale(port: u16, their: &str) -> bool {+ eprintln!("{APP} {their} is serving on {port} but this is {VERSION}; retiring the stale instance");+ let me = std::process::id();+ let mut victims: Vec<u32> = Vec::new();+ if let Ok(rd) = std::fs::read_dir("/proc") {+ for e in rd.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ let pid: u32 = match name.parse() {+ Ok(p) => p,+ Err(_) => continue,+ };+ if pid == me {+ continue;+ }+ let cmd = match std::fs::read(format!("/proc/{pid}/cmdline")) {+ Ok(c) => c,+ Err(_) => continue,+ };+ let argv: Vec<&[u8]> = cmd.split(|b| *b == 0).collect();+ if argv.len() < 2 {+ continue;+ }+ let bin = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);+ let is_us = bin == b"adom-chip-fetcher" || bin == b"chip-fetcher";+ if is_us && argv[1] == b"serve" {+ victims.push(pid);+ }+ }+ }+ for pid in &victims {+ let _ = std::process::Command::new("kill").args(["-TERM", &pid.to_string()]).output();+ }+ for _ in 0..40 {+ if running_version(port).is_none() {+ return true;+ }+ std::thread::sleep(Duration::from_millis(100));+ }+ for pid in &victims {+ let _ = std::process::Command::new("kill").args(["-KILL", &pid.to_string()]).output();+ }+ std::thread::sleep(Duration::from_millis(500));+ running_version(port).is_none()+}++/// Take ownership of the dock's pre-opened webview tab. The dock names the+/// tab in `ADOM_DOCK_WEBVIEW`; `open-or-refresh` with that name is idempotent+/// so re-running it after a restart just renavigates. Unset env (manual+/// serve) is a silent no-op; the dock's own interstitial navigation remains+/// the fallback.+pub fn claim_dock_webview(port: u16) {+ let name = match std::env::var("ADOM_DOCK_WEBVIEW") {+ Ok(n) if !n.trim().is_empty() => n,+ _ => return,+ };+ let url = direct_url(port);+ let _ = std::process::Command::new("timeout")+ .args(["20", "adom-cli", "hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status();+ console_push("info", &format!("claimed dock webview tab '{name}' -> {url}"), "serve");+}++/// Spawn a detached copy of ourselves to serve, wait until it answers+/// `/health`, and return its URL. This is how `serve --print-url`+/// self-backgrounds without `fork()` (tiny_http owns threads; forking a+/// threaded process is undefined behaviour).+pub fn spawn_detached_server(port: u16) -> anyhow::Result<String> {+ use std::os::unix::process::CommandExt;+ let exe = std::env::current_exe()?;+ let mut cmd = std::process::Command::new(exe);+ cmd.args(["serve", "--port", &port.to_string(), "--detached-child"])+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ // New session: the dock's exec pipe must not stay open on us, and the+ // exec's process-tree kill at timeout must not take the server with it.+ unsafe {+ cmd.pre_exec(|| {+ libc_setsid();+ Ok(())+ });+ }+ cmd.spawn()?;+ let deadline = Instant::now() + Duration::from_secs(15);+ while Instant::now() < deadline {+ if running_version(port).is_some() {+ return Ok(direct_url(port));+ }+ std::thread::sleep(Duration::from_millis(100));+ }+ anyhow::bail!("the detached server never answered /health on port {port} within 15s")+}++/// `setsid(2)` without the libc crate: the syscall number is stable on+/// x86_64 (112) and aarch64 (112) Linux. Failure is harmless (we keep the+/// parent's session), so the return value is ignored.+fn libc_setsid() {+ extern "C" {+ fn setsid() -> i32;+ }+ unsafe {+ setsid();+ }+}++// ------------------------------------------------------------------ say / try++/// The `say` phrases from dockbar.json, the single source both the dock's+/// interstitial and the dashboard's "Say it" strip read.+pub fn say_phrases() -> Vec<String> {+ serde_json::from_str::<Value>(DOCKBAR_JSON)+ .ok()+ .and_then(|v| v.get("say").cloned())+ .and_then(|s| s.as_array().cloned())+ .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())+ .unwrap_or_default()+}++/// Stage a phrase into this app's own AI thread via the Hydrogen control API.+/// Measured order (dock thread, 2026-08-23): the census FIRST (open threads),+/// history SECOND (closed ones; it deliberately excludes anything open, so+/// history-first returns empty exactly when the user has the thread open),+/// copy fallback third. The control API is reachable from WSL2 under mirrored+/// networking; in a cloud container it is not, and copying is the right+/// answer there.+pub fn try_phrase(phrase: &str) -> Value {+ let t = Duration::from_millis(1500);+ let get = |path: &str| -> Option<Value> {+ http_get(HYDROGEN_CONTROL_PORT, path, t).and_then(|b| serde_json::from_str(&b).ok())+ };+ let post = |path: &str, body: Value| -> Option<Value> {+ http_post(HYDROGEN_CONTROL_PORT, path, &body.to_string(), t).and_then(|b| serde_json::from_str(&b).ok())+ };+ // 1. census+ if let Some(c) = get("/ai-threads") {+ let mut hit: Option<Value> = None;+ for g in c.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {+ for th in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {+ let tagged = th+ .get("tags")+ .and_then(|t| t.as_array())+ .map(|a| a.iter().any(|x| x.as_str() == Some(APP_TAG)))+ .unwrap_or(false);+ if tagged {+ hit = Some(th);+ break;+ }+ }+ if hit.is_some() {+ break;+ }+ }+ if let Some(th) = hit {+ let mut body = json!({"prompt": phrase, "submit": false});+ match th.get("sid").and_then(|s| s.as_str()) {+ Some(sid) if !sid.is_empty() => body["sid"] = json!(sid),+ _ => body["tid"] = th.get("tid").cloned().unwrap_or(Value::Null),+ }+ return match post("/ai-threads/run-prompt", body) {+ Some(r) if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(true) => {+ json!({"staged": true, "where": "open thread"})+ }+ _ => json!({"staged": false, "reason": "run-prompt failed on the open thread"}),+ };+ }+ // 2. history+ if let Some(h) = get(&format!("/ai-threads/history?tag={APP_TAG}")) {+ if let Some(it) = h.get("items").and_then(|i| i.as_array()).and_then(|a| a.first()).cloned() {+ let id = it.get("id").cloned().unwrap_or(Value::Null);+ let ai = it.get("ai").cloned().unwrap_or(json!("claude"));+ let resumed = post("/ai-threads/resume", json!({"ai": ai, "id": id}));+ if resumed.is_some() {+ let r = post("/ai-threads/run-prompt", json!({"sid": id, "prompt": phrase, "submit": false}));+ if r.is_some() {+ return json!({"staged": true, "where": "resumed thread"});+ }+ }+ return json!({"staged": false, "reason": "resume/run-prompt failed on the closed thread"});+ }+ }+ return json!({"staged": false, "reason": "no chip-fetcher AI thread yet; start one by pasting the phrase"});+ }+ json!({"staged": false, "reason": "the Hydrogen control API is not reachable from this container"})+}++// ------------------------------------------------------- console + toasts++static CONSOLE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());+static TOASTS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());+static UI_CMDS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());+const CONSOLE_CAP: usize = 500;++fn now_ms() -> u64 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis() as u64)+ .unwrap_or(0)+}++/// Ring buffer the dashboard's console.* override and the server both write+/// to, read back by `GET /console`. This is how the AI sees what the UI is+/// doing without DevTools.+pub fn console_push(level: &str, msg: &str, source: &str) {+ if let Ok(mut q) = CONSOLE.lock() {+ q.push_back(json!({"t": now_ms(), "level": level, "msg": msg, "source": source}));+ while q.len() > CONSOLE_CAP {+ q.pop_front();+ }+ }+}++pub fn console_json(limit: usize) -> String {+ let q = CONSOLE.lock().map(|q| q.iter().rev().take(limit).cloned().collect::<Vec<_>>()).unwrap_or_default();+ let mut items: Vec<Value> = q;+ items.reverse();+ json!({"ok": true, "count": items.len(), "items": items}).to_string()+}++/// Queue a toast for the dashboard. The dashboard drains the queue on its+/// 1s activity poll, so `adom-chip-fetcher ui toast "..."` lands over the+/// app within a second. Kinds: ok | err | info.+pub fn toast_push(message: &str, kind: &str) {+ if let Ok(mut q) = TOASTS.lock() {+ q.push_back(json!({"t": now_ms(), "message": message, "kind": kind}));+ while q.len() > 20 {+ q.pop_front();+ }+ }+}++pub fn drain_toasts() -> Vec<Value> {+ TOASTS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()+}++/// Queue a front-end driving command (`{"cmd":"click","id":"btn-settings"}`,+/// `{"cmd":"open_settings"}`, `{"cmd":"select","mpn":"..."}`). Same drain.+pub fn ui_command_push(cmd: Value) {+ if let Ok(mut q) = UI_CMDS.lock() {+ q.push_back(cmd);+ while q.len() > 50 {+ q.pop_front();+ }+ }+}++pub fn drain_ui_commands() -> Vec<Value> {+ UI_CMDS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()+}++// ------------------------------------------------------------- status LEDs++fn on_path(bin: &str) -> bool {+ let extra = std::env::var("HOME").map(|h| format!("{h}/.local/bin")).unwrap_or_default();+ let path = std::env::var("PATH").unwrap_or_default();+ std::iter::once(extra.as_str())+ .chain(path.split(':'))+ .filter(|d| !d.is_empty())+ .any(|d| std::path::Path::new(d).join(bin).is_file())+}++fn led(label: &str, state: &str, detail: String, hint: &str) -> Value {+ json!({"label": label, "state": state, "detail": detail, "_hint": hint})+}++fn rank(state: &str) -> u8 {+ match state {+ "fail" | "err" => 3,+ "warn" => 2,+ "ok" => 1,+ _ => 0,+ }+}++/// Whether a desktop is connected through Adom Bridge. `adom-bridge targets`+/// is the authoritative probe; it answers in well under a second when the+/// relay is up and the 2s timeout bounds the dead case.+fn bridge_targets() -> Option<usize> {+ let bin = if on_path("adom-bridge") { "adom-bridge" } else { "adom-bridge-cli" };+ let out = std::process::Command::new("timeout").args(["2", bin, "targets"]).output().ok()?;+ let text = String::from_utf8_lossy(&out.stdout);+ // The CLI prefixes a "note:" line for the retired alias; find the JSON.+ let start = text.find('{')?;+ let v: Value = serde_json::from_str(&text[start..]).ok()?;+ v.get("count").and_then(|c| c.as_u64()).map(|c| c as usize)+}++pub struct Facets {+ pub leds: serde_json::Map<String, Value>,+ pub state: String,+ pub missing_tools: Vec<String>,+}++/// Build every facet. Cheap checks only (PATH stats, one bounded bridge+/// probe, the library listing), cached by `status_json`.+pub fn build_facets() -> Facets {+ let mut leds = serde_json::Map::new();++ // 1. the dashboard itself: answering means ok.+ leds.insert(+ "dashboard".into(),+ led("Dashboard", "ok", format!("v{VERSION} serving"), "The dashboard answers /api/status; the dock polls it every 15s."),+ );++ // 2. the chip-fetcher skill on this container.+ let home = std::env::var("HOME").unwrap_or_default();+ let skill = std::path::Path::new(&home).join(".claude/skills/adom-chip-fetcher/SKILL.md");+ leds.insert(+ "skill".into(),+ if skill.is_file() {+ led("AI skill", "ok", "chip-fetcher skill installed".into(), "The AI reads ~/.claude/skills/adom-chip-fetcher/SKILL.md to know how to drive this app.")+ } else {+ led("AI skill", "warn", "skill not installed".into(), "Run `adom-chip-fetcher install` so your AI learns the sourcing ladder and the rules.")+ },+ );++ // 3. sister tools.+ let missing: Vec<String> = SISTER_TOOLS.iter().filter(|(b, _)| !on_path(b)).map(|(b, _)| b.to_string()).collect();+ let total = SISTER_TOOLS.len();+ leds.insert(+ "tools".into(),+ if missing.is_empty() {+ led("Sister tools", "ok", format!("{total}/{total} present"), "Symbol, footprint, 3D, Fusion export, pin-1 sign-off, thumbnails and the distributor backends are all on PATH.")+ } else {+ led(+ "Sister tools",+ "warn",+ format!("{}/{} present, missing {}", total - missing.len(), total, missing.join(", ")),+ "Run `adom-wiki pkg install adom/adom-chip-fetcher` (it installs every dependency), or `adom-chip-fetcher doctor --fix`.",+ )+ },+ );++ // 4. the desktop bridge (pup lives there; no desktop means no scraping).+ leds.insert(+ "bridge".into(),+ match bridge_targets() {+ Some(n) if n > 0 => led("Desktop", "ok", format!("{n} box(es) on Adom Bridge"), "pup drives the chip-fetcher Chrome profile on a connected desktop."),+ Some(_) => led("Desktop", "warn", "no desktop connected".into(), "Open Adom Hydrogen on your desktop; chip-fetcher scrapes vendor sites through pup there. The library view works without it."),+ None => led("Desktop", "warn", "adom-bridge not answering".into(), "Install the bridge CLI (`adom-wiki pkg install adom/adom-bridge`) and open Adom Hydrogen on your desktop."),+ },+ );++ // 5. the library.+ let (parts, complete) = library_counts();+ leds.insert(+ "library".into(),+ if parts == 0 {+ led("Library", "ok", "no parts yet".into(), "Ask your AI: \"fetch STM32F103C8T6 with chip fetcher\". The first card appears within seconds.")+ } else if complete == parts {+ led("Library", "ok", format!("{parts} parts, all complete"), "Every card has datasheet + STEP + symbol + footprint.")+ } else {+ led("Library", "ok", format!("{parts} parts, {} still incomplete", parts - complete), "Incomplete cards are work in progress, not a fault. `adom-chip-fetcher gate` lists exactly which artifact each one is missing.")+ },+ );++ // rollup: worst facet; "off" facets never count.+ let worst = leds.values().map(|v| v.get("state").and_then(|s| s.as_str()).unwrap_or("off")).max_by_key(|s| rank(s)).unwrap_or("ok");+ let state = match worst {+ "fail" | "err" => "fail",+ "warn" => "warn",+ _ => "ok",+ }+ .to_string();+ Facets { leds, state, missing_tools: missing }+}++fn library_counts() -> (usize, usize) {+ let root = crate::library::root();+ let mut parts = 0;+ let mut complete = 0;+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ let p = e.path();+ if !p.is_dir() {+ continue;+ }+ let mpn = e.file_name().to_string_lossy().to_string();+ if mpn.starts_with('.') {+ continue;+ }+ parts += 1;+ let has = |ext: &str| p.join(format!("{mpn}.{ext}")).is_file();+ if has("pdf") && has("step") && has("kicad_sym") && has("kicad_mod") {+ complete += 1;+ }+ }+ }+ (parts, complete)+}++static STATUS_CACHE: Mutex<Option<(Instant, String)>> = Mutex::new(None);++/// `/api/status`: per-facet LEDs plus a top-level `state` rollup the dock+/// honours. Cached 10s so the dock's 15s poll always answers in+/// milliseconds (the bridge probe is the slow part).+pub fn status_json(force: bool) -> String {+ if !force {+ if let Ok(c) = STATUS_CACHE.lock() {+ if let Some((t, s)) = c.as_ref() {+ if t.elapsed() < Duration::from_secs(10) {+ return s.clone();+ }+ }+ }+ }+ let f = build_facets();+ let body = json!({+ "ok": true,+ "app": APP,+ "version": VERSION,+ "state": f.state,+ "leds": f.leds,+ "say": say_phrases(),+ "missing_tools": f.missing_tools,+ "_hint": "GET /api/library for the cards, GET /api/activity for the live heartbeat, POST /ui/toast {message,kind} to raise a toast, POST /ui/command {cmd,...} to drive the front-end.",+ })+ .to_string();+ if let Ok(mut c) = STATUS_CACHE.lock() {+ *c = Some((Instant::now(), body.clone()));+ }+ body+}++pub fn health_json() -> String {+ json!({"ok": true, "app": APP, "version": VERSION}).to_string()+}
src/enrich.rs+5−5@@ -1,8 +1,8 @@ //! First-class integration with the Adom parts-data tools. //! //! THE LESSON (learned the hard way 2026-06-23): the Adom parts tools-//! — `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` +-//! `adom-jlcpcb`) — keep their API keys baked into their OWN service containers.+//!, `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` ++//! `adom-jlcpcb`), keep their API keys baked into their OWN service containers. //! They ALWAYS work with NO key needed. chip-fetcher must NEVER assume it needs //! a `MOUSER_API_KEY` of its own; it just shells to `adom-parts-search`. //!@@ -42,7 +42,7 @@ pub fn parts_search(query: &str) -> Result<Value> { .arg("search") .arg(query) .output()- .with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}` — is adom-parts-search installed?"))?;+ .with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}`, is adom-parts-search installed?"))?; let stdout = String::from_utf8_lossy(&out.stdout); // The CLI prints a one-line "OK: …" summary before the JSON body; find the // first '{' and parse from there.@@ -65,7 +65,7 @@ fn all_hits(data: &Value) -> Vec<Value> { hits } -/// The orderable package variants for a (possibly base) MPN — the exact strings+/// The orderable package variants for a (possibly base) MPN, the exact strings /// CAD sites need. e.g. ADS1263 → ["ADS1263IPW", "ADS1263IPWR"]. Dedup, /// preserving Mouser-first order. This is the permanent orderable-MPN resolver. pub fn resolve_orderable(mpn: &str) -> Vec<String> {@@ -184,7 +184,7 @@ pub fn enrich(mpn: &str) -> Result<EnrichReport> { .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(|| json!({})); // Only set the description from the distributor if there isn't already a- // (likely hand-curated, nicer) one — don't clobber a better description.+ // (likely hand-curated, nicer) one, don't clobber a better description. let existing = info.get("description").and_then(|d| d.as_str()).unwrap_or("").trim().to_string(); if existing.len() < 10 { let raw = h.get("description").and_then(|x| x.as_str()).unwrap_or("");
src/hints.rs+87−70@@ -1,6 +1,6 @@ // Self-enforcing rails for adom-chip-fetcher: heartbeat the dashboard, check that // state is healthy before the next pup action, and emit `HINT:` lines that-// the calling AI reads back into context — so the binary itself becomes the+// the calling AI reads back into context, so the binary itself becomes the // source of truth for "what to do next" instead of relying on skill prose. use anyhow::Result;@@ -13,23 +13,23 @@ const STALE_AFTER_SECS: u64 = 60; const DEFAULT_PORT: u16 = 8786; const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json"; -/// Canonical sourcing-ladder priority. Single source of truth — `adom-chip-fetcher+/// Canonical sourcing-ladder priority. Single source of truth, `adom-chip-fetcher /// sources` and the playbook prose both read from here. **Always traverse in /// order; never skip to LCSC.** Direct user rule (2026-05-06): *"after Mouser /// you should do digikey, and then only after that try LCSC. I really want to /// avoid LCSC at all costs, but it is in the list if we're desperate."* pub const SOURCE_LADDER: &[(&str, &str)] = &[- ("adom-wiki", "wiki.adom.inc — reuse an already-published, Adom-vetted CAD bundle. TRY FIRST: if the part exists here we skip the external vendors entirely (free, fast, Adom-consistent geometry + provenance)."),- ("manufacturer", "vendor.com — STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),- ("snapmagic", "snapeda.com — free CAD bundles, login-gated. Right after manufacturer."),- ("mouser", "mouser.com — stock + pricing always; sometimes ECAD Models / CSE deep-links."),- ("digikey", "digikey.com — broader catalog; STEP + datasheet on every product page."),- ("arrow", "arrow.com — niche distributor."),- ("cse_ul", "componentsearchengine.com / ultralibrarian.com — only when mfr links there."),- ("lcsc", "datasheet.lcsc.com / lcsc.com — LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."),+ ("adom-wiki", "wiki.adom.inc, reuse an already-published, Adom-vetted CAD bundle. TRY FIRST: if the part exists here we skip the external vendors entirely (free, fast, Adom-consistent geometry + provenance)."),+ ("manufacturer", "vendor.com, STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),+ ("snapmagic", "snapeda.com, free CAD bundles, login-gated. Right after manufacturer."),+ ("mouser", "mouser.com, stock + pricing always; sometimes ECAD Models / CSE deep-links."),+ ("digikey", "digikey.com, broader catalog; STEP + datasheet on every product page."),+ ("arrow", "arrow.com, niche distributor."),+ ("cse_ul", "componentsearchengine.com / ultralibrarian.com, only when mfr links there."),+ ("lcsc", "datasheet.lcsc.com / lcsc.com, LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."), ]; -/// Where the user wants to see the dashboard. Pup is the default — adom-chip-fetcher+/// Where the user wants to see the dashboard. Pup is the default, adom-chip-fetcher /// needs pup anyway to drive vendor sites, so the dashboard tagging along is /// the lowest-friction path. Hydrogen webview is the alternative for users on /// big monitors who prefer the workspace-integrated panel.@@ -134,7 +134,7 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { // Stub-card on first heartbeat: if the chip has no library/<MPN>/ directory // yet, create one with an in-progress info.json so the dashboard renders an // empty placeholder card immediately. Without this, the user has nothing- // to scroll to during a fresh fetch — the chip is invisible until the+ // to scroll to during a fresh fetch, the chip is invisible until the // first file lands. let was_new = ensure_stub_card(mpn, stage).unwrap_or(false); @@ -150,29 +150,29 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { .unwrap_or(false); println!("OK: heartbeat {mpn} → {stage:?}");+ taskbar_for_stage(mpn, stage); if !dashboard_ok { println!("WARN: dashboard unreachable at {url}. Heartbeat cached locally."); print_hint("start the dashboard with `adom-chip-fetcher app` so users can see live progress"); } // Auto-scroll the MPN's dashboard card into view in the adom-chip-fetcher pup- // session. This is the rule John asked for — the binary enforces "scroll+ // session. This is the rule John asked for, the binary enforces "scroll // the active card into focus" automatically rather than relying on the AI // to remember it on every heartbeat. No-op if pup isn't running. match auto_scroll_card(mpn) { Ok(true) => println!("OK: scrolled card {mpn} into focus on dashboard"),- Ok(false) => {} // pup not up, or dashboard surface is webview — silently skip+ Ok(false) => {} // pup not up, or dashboard surface is webview, silently skip Err(e) => println!("WARN: could not scroll card {mpn} into focus: {e}"), } // First heartbeat for this MPN: print the sourcing-ladder reminder so the- // AI doesn't skip ahead to LCSC. This is "rules made programmatic" —- // every fresh fetch sees the canonical order in stdout, not buried in a+ // AI doesn't skip ahead to LCSC. This is "rules made programmatic", // every fresh fetch sees the canonical order in stdout, not buried in a // skill file the AI may not have re-read this turn. if was_new { println!(); print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `adom-chip-fetcher sources` for the full priority list."));- print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT — surface 'no manufacturer CAD' to user before reaching for it.");+ print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT, surface 'no manufacturer CAD' to user before reaching for it."); } // Card-completeness check on every heartbeat. The AI must not claim "done"@@ -201,8 +201,34 @@ pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> { /// A QUIET heartbeat: refresh the dashboard's activity timestamp + stage only. /// No stdout, no stub creation, no auto-scroll, no chain/stage bookkeeping. /// This is what the background ticker fires every few seconds so the "working-/// on" bar stays LIVE during a long blocking op — without spamming the log or+/// on" bar stays LIVE during a long blocking op, without spamming the log or /// re-running the heavy per-heartbeat side-effects.+/// The taskbar bar rides the heartbeat protocol, so an AI walking the ladder+/// by hand (`adom-chip-fetcher heartbeat <MPN> "<stage>"`) gets it for free:+/// any non-terminal stage keeps an INDETERMINATE bar on the dashboard window+/// with the stage as its tooltip; a terminal stage ("imported", "done",+/// "stuck", "exhausted") clears it. Paints only when the note changes, since+/// every paint is one bridge process.+pub fn taskbar_for_stage(mpn: &str, stage: &str) {+ use std::sync::Mutex;+ static LAST: Mutex<Option<String>> = Mutex::new(None);+ let s = stage.to_ascii_lowercase();+ let terminal = crate::sourcing::stage_phase(stage) == Some(6)+ || s.contains("stuck") || s.contains("exhausted") || s.contains("failed") || s.contains("abandon");+ let note = if terminal { String::new() } else { format!("{mpn}: {stage}") };+ if let Ok(mut last) = LAST.lock() {+ if last.as_deref() == Some(note.as_str()) {+ return;+ }+ *last = Some(note.clone());+ }+ if terminal {+ crate::taskbar::clear();+ } else {+ crate::taskbar::start_indeterminate(¬e);+ }+}+ pub fn heartbeat_quiet(mpn: &str, stage: &str) { let hb = Heartbeat { mpn: mpn.to_string(), stage: stage.to_string(), ts: now_secs() }; if let Ok(body) = serde_json::to_string(&hb) {@@ -218,7 +244,7 @@ pub fn heartbeat_quiet(mpn: &str, stage: &str) { /// browser XHR, a slow import). Emits one loud heartbeat up front, then a quiet /// one every ~6s on a background thread until stopped (or dropped). Without this /// the bar goes stale after ACTIVITY_STALE_SECS mid-operation and the user sees-/// no live status — exactly John's complaint.+/// no live status, exactly John's complaint. pub struct HeartbeatTicker { stop: std::sync::Arc<std::sync::atomic::AtomicBool>, handle: Option<std::thread::JoinHandle<()>>,@@ -260,7 +286,7 @@ impl Drop for HeartbeatTicker { /// Track stage-order per MPN in /tmp/adom-chip-fetcher-stage-<MPN>.json. Warn /// when the AI heartbeats a later phase (e.g. 5=importing) without having-/// hit the prior phase (3=downloading). Doesn't block — just nudges.+/// hit the prior phase (3=downloading). Doesn't block, just nudges. fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { let phase = match crate::sourcing::stage_phase(stage) { Some(p) => p,@@ -268,7 +294,7 @@ fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { }; let path = std::path::PathBuf::from(format!("/tmp/adom-chip-fetcher-stage-{mpn}.json")); // Did we ever record a phase before? Distinguish "no prev recorded"- // from "prev was phase 0 (queued)" — both are u8 0 but only one is+ // from "prev was phase 0 (queued)", both are u8 0 but only one is // a legitimate prior state. let had_prev = path.exists(); let prev: u8 = if had_prev {@@ -285,7 +311,7 @@ fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { let phase_names = ["queued", "probing", "found", "downloading", "pulling", "importing", "imported"]; let prev_name = phase_names.get(prev as usize).copied().unwrap_or("?"); let cur_name = phase_names.get(phase as usize).copied().unwrap_or("?");- println!("WARN: stage jumped {prev}→{phase} for {mpn} ({prev_name}→{cur_name}) — skipped intermediate stages. Did the file actually land? Did you heartbeat \"downloading\" / \"pulling\" / \"importing\"?");+ println!("WARN: stage jumped {prev}→{phase} for {mpn} ({prev_name}→{cur_name}), skipped intermediate stages. Did the file actually land? Did you heartbeat \"downloading\" / \"pulling\" / \"importing\"?"); } let _ = std::fs::write(&path, serde_json::json!({"phase": phase, "stage": stage}).to_string());@@ -295,8 +321,7 @@ fn check_stage_order(mpn: &str, stage: &str) -> Result<()> { /// Walk the active chip's directory and print explicit `WARN:` + `HINT:` lines /// for each missing core artifact. Also counts how many OTHER chips in the /// library are incomplete so the AI sees the batch picture, not just one chip.-/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate` —-/// this is the inline version that fires on every heartbeat.+/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate`, /// this is the inline version that fires on every heartbeat. fn print_completeness_warnings(mpn: &str) -> Result<()> { let lib = match crate::inventory::scan() { Ok(l) => l,@@ -316,7 +341,7 @@ fn print_completeness_warnings(mpn: &str) -> Result<()> { if !c.has_mod { missing.push("KiCad .kicad_mod"); } if !missing.is_empty() { println!();- println!("WARN: card {mpn} is INCOMPLETE — missing: {}", missing.join(", "));+ println!("WARN: card {mpn} is INCOMPLETE, missing: {}", missing.join(", ")); print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `adom-chip-fetcher gate` for full status.")); } }@@ -347,8 +372,8 @@ fn auto_scroll_card(mpn: &str) -> Result<bool> { "sessionId": "adom-chip-fetcher-dashboard", "expr": expr, }).to_string();- let out = Command::new("adom-desktop")- .args(["browser_eval", &body])+ let out = crate::bridge::command()+ .args(["pup_eval", &body]) .output()?; Ok(out.status.success()) }@@ -362,18 +387,17 @@ pub fn age_secs(hb: &Heartbeat) -> u64 { now_secs().saturating_sub(hb.ts) } -/// Make sure the dashboard is up AND visible to the user. Idempotent —-/// running this every time is the point. Three checks, each with a fix:+/// Make sure the dashboard is up AND visible to the user. Idempotent, /// running this every time is the point. Three checks, each with a fix: /// 1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `adom-chip-fetcher /// serve` detached. /// 2. Pup window with sessionId "adom-chip-fetcher-dashboard" pointing at the dashboard? /// If not, open one (or open Hydrogen tab, per surface preference). /// 3. (best-effort) Foreground the pup window so the user sees it. ///-/// `force_open` skips check (2)'s "already open" path and re-opens — useful+/// `force_open` skips check (2)'s "already open" path and re-opens, useful /// when the user explicitly wants the dashboard front-and-center. pub fn dashboard_ensure(force_open: bool) -> Result<()> {- // Step 1 — HTTP server+ // Step 1, HTTP server if !dashboard_reachable() { println!("WARN: dashboard server not running; starting it..."); let exe = std::env::current_exe()@@ -399,13 +423,13 @@ pub fn dashboard_ensure(force_open: bool) -> Result<()> { if dashboard_reachable() { println!("OK: dashboard server up on http://127.0.0.1:{DEFAULT_PORT}/"); } else {- anyhow::bail!("dashboard server failed to start within 3s — see /tmp/cf-dashboard.log");+ anyhow::bail!("dashboard server failed to start within 3s, see /tmp/cf-dashboard.log"); } } else { println!("OK: dashboard server already running on :{DEFAULT_PORT}"); } - // Step 2 — visible surface+ // Step 2, visible surface let surface = get_dashboard_surface(); match surface { DashboardSurface::Pup => ensure_pup_dashboard_window(force_open)?,@@ -415,7 +439,7 @@ pub fn dashboard_ensure(force_open: bool) -> Result<()> { } fn dashboard_proxy_url() -> String {- // The dashboard pup window runs on the user's Windows desktop — it can't+ // The dashboard pup window runs on the user's Windows desktop, it can't // reach 127.0.0.1 on the Docker container. Use the Adom cloud proxy URL // so the pup browser can access the dashboard through HTTPS. if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {@@ -430,24 +454,14 @@ fn dashboard_proxy_url() -> String { } fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> {- // Native browser: the dashboard is TAB 1 of the single "adom-chip-fetcher" window- // (scrape sites are tabs beside it). Never spawn a second window — that's- // the "why do I have a native browser AND a pup?" bug.- if crate::pup::is_native() {- let url = dashboard_proxy_url();- if !force_open && crate::pup::session_has_url("adom-chip-fetcher", &format!("/proxy/{DEFAULT_PORT}/")) {- println!("OK: dashboard is tab 1 of the adom-chip-fetcher window (native browser)");- return Ok(());- }- crate::pup::open_window("adom-chip-fetcher", "adom-chip-fetcher", &url)?;- println!("OK: opened the adom-chip-fetcher dashboard as tab 1 of your native browser window");- return Ok(());- }+ // The dashboard is ALWAYS a pup window (John, 2026-08-23: show and test the+ // dashboard in pup), whatever transport does the scraping. The native+ // browser is for vendor sites only. let already_open = pup_session_open("adom-chip-fetcher-dashboard").unwrap_or(false); if already_open && !force_open { println!("OK: dashboard pup window 'adom-chip-fetcher-dashboard' already open");- let _ = Command::new("adom-desktop")- .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])+ let _ = crate::bridge::command()+ .args(["pup_alert_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"]) .output(); return Ok(()); }@@ -457,14 +471,17 @@ fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> { "profile": "adom-chip-fetcher", "url": url, }).to_string();- let out = Command::new("adom-desktop")- .args(["browser_open_window", &body])+ let out = crate::bridge::command()+ .args(["pup_open_window", &body]) .output(); match out { Ok(o) if o.status.success() => { println!("OK: opened dashboard in pup at {}", dashboard_proxy_url());- let _ = Command::new("adom-desktop")- .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])+ // Maximize ONCE, right after opening (John's pup rule). The page+ // title carries "Chip Fetcher", which is how the window is found.+ std::thread::sleep(std::time::Duration::from_millis(1200));+ let _ = crate::bridge::command()+ .args(["desktop_set_window_state", "{\"titleContains\":\"Chip Fetcher\",\"state\":\"maximize\"}"]) .output(); Ok(()) }@@ -474,7 +491,7 @@ fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> { let detail = if stdout.is_empty() { stderr } else { stdout }; anyhow::bail!("browser_open_window failed: {detail}") }- Err(e) => anyhow::bail!("could not run adom-desktop: {e}"),+ Err(e) => anyhow::bail!("could not run adom-bridge: {e}"), } } @@ -491,7 +508,7 @@ fn ensure_hydrogen_dashboard_tab(_force_open: bool) -> Result<()> { Ok(()) } _ => {- println!("WARN: adom-cli workspace tab-open failed — falling back to pup");+ println!("WARN: adom-cli workspace tab-open failed, falling back to pup"); ensure_pup_dashboard_window(false) } }@@ -513,9 +530,9 @@ fn pup_session_open(name: &str) -> Option<bool> { // First lookup miss: maybe the bridge dropped + reconnected and lost the // session reference even though Chrome is still alive (the v1.5.0 bug). // v1.5.1's browser_rescan rebuilds the session map from the actual- // Chrome targets — call it once before declaring the session gone.- let rescan = Command::new("adom-desktop")- .args(["browser_rescan", "{}"])+ // Chrome targets, call it once before declaring the session gone.+ let rescan = crate::bridge::command()+ .args(["pup_rescan", "{}"]) .output() .ok()?; if !rescan.status.success() { return Some(false); }@@ -532,8 +549,8 @@ fn pup_session_open(name: &str) -> Option<bool> { } fn list_contains_session(name: &str) -> Option<bool> {- let out = Command::new("adom-desktop")- .args(["browser_list_windows", "{}"])+ let out = crate::bridge::command()+ .args(["pup_list_windows", "{}"]) .output() .ok()?; let s = String::from_utf8_lossy(&out.stdout);@@ -544,7 +561,7 @@ fn list_contains_session(name: &str) -> Option<bool> { pub fn selfcheck() -> Result<()> { // Dashboard rail: auto-(re)open if missing rather than just complaining.- // The user shouldn't have to remember "is the dashboard up?" — that's+ // The user shouldn't have to remember "is the dashboard up?", that's // the binary's job. if let Err(e) = dashboard_ensure(false) { println!("WARN: dashboard_ensure: {e}");@@ -566,27 +583,27 @@ pub fn selfcheck() -> Result<()> { let age = age_secs(&hb); if age <= STALE_AFTER_SECS { println!(- "OK: heartbeat {age}s old — {} → {:?}",+ "OK: heartbeat {age}s old, {} → {:?}", hb.mpn, hb.stage ); } else { warns.push(format!(- "heartbeat is {age}s old (stale-after = {STALE_AFTER_SECS}s) — last was {} → {:?}",+ "heartbeat is {age}s old (stale-after = {STALE_AFTER_SECS}s), last was {} → {:?}", hb.mpn, hb.stage )); } } None => warns.push(- "no heartbeat on file — the dashboard hasn't seen any activity from this session".into(),+ "no heartbeat on file, the dashboard hasn't seen any activity from this session".into(), ), } match pup_session_open("adom-chip-fetcher") { Some(true) => println!("OK: pup session 'adom-chip-fetcher' is open"), Some(false) => warns.push(- "pup session 'adom-chip-fetcher' is NOT open — fetches need this exact sessionId".into(),+ "pup session 'adom-chip-fetcher' is NOT open, fetches need this exact sessionId".into(), ),- None => warns.push("could not query adom-desktop browser_list_windows".into()),+ None => warns.push("could not query adom-bridge pup_list_windows".into()), } if !errs.is_empty() {@@ -604,15 +621,15 @@ pub fn selfcheck() -> Result<()> { println!(); if errs.is_empty() && warns.is_empty() {- print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");+ print_hint("all checks passed, continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action."); } else { if !dashboard_reachable() {- print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");+ print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only), every other rule depends on the dashboard being up."); } match last() { None => print_hint("send your first heartbeat: `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"), Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(- "heartbeat is stale — refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`",+ "heartbeat is stale, refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`", hb.mpn )), _ => {}@@ -633,7 +650,7 @@ pub fn print_hint(line: &str) { } /// Emit a "what to do next" line based on current state. Used as the tail of-/// every subcommand's stdout — the AI reads this back into context and the+/// every subcommand's stdout, the AI reads this back into context and the /// next action becomes mechanical instead of remembered. pub fn print_next_after(stage: &str, mpn: Option<&str>) { match (stage, mpn) {
src/import.rs+21−22@@ -4,7 +4,7 @@ use std::process::Command; use crate::library; -/// Per-file upstream provenance — which ladder source + URL each file+/// Per-file upstream provenance, which ladder source + URL each file /// came from. Passed into import_path and written to /// `<MPN>-file-provenance.json` so downstream tools (chipsmith tooltips, /// wiki chip pages, concur cross-validation notes) can attribute every@@ -68,7 +68,7 @@ pub fn import_path(src: &Path, mpn_override: Option<&str>) -> Result<ImportRepor let mpn = match mpn_override { Some(m) => m.to_string(), None => infer_mpn(src)- .ok_or_else(|| anyhow!("could not infer MPN from path '{}' — pass --mpn", src.display()))?,+ .ok_or_else(|| anyhow!("could not infer MPN from path '{}', pass --mpn", src.display()))?, }; let dest = library::ensure_dir(&mpn)?;@@ -89,8 +89,7 @@ fn ingest_file(file: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> "zip" => unzip_and_ingest(file, mpn, dest, added), "step" | "stp" | "pdf" | "kicad_mod" | "kicad_sym" | "wrl" => { // Magic-byte validation. Catches the "naked curl returned a- // 174 KB HTML denial page that looks like 200 OK" footgun —- // PDFs are common, STEPs are common, and both have unambiguous+ // 174 KB HTML denial page that looks like 200 OK" footgun, // PDFs are common, STEPs are common, and both have unambiguous // magic strings. Refuse the import if the bytes don't match. validate_magic_bytes(file, &ext) .with_context(|| format!("magic-byte check failed for {}", file.display()))?;@@ -142,7 +141,7 @@ fn unzip_and_ingest(zip: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) .arg(zip) .arg("-d").arg(&tmp) .status()- .context("running unzip — install `unzip` if missing")?;+ .context("running unzip, install `unzip` if missing")?; if !status.success() { return Err(anyhow!("unzip failed for {}", zip.display())); }@@ -172,7 +171,7 @@ fn walk_recursive(dir: &Path) -> Result<Vec<PathBuf>> { } /// Infer an MPN from the path. Looks at the filename (and parent zip name)-/// for a token that looks like a part number — letters mixed with digits.+/// for a token that looks like a part number, letters mixed with digits. fn infer_mpn(path: &Path) -> Option<String> { let stem = path.file_stem()?.to_str()?; for token in stem.split(|c: char| c == '-' || c == '_' || c == ' ' || c == '.') {@@ -182,7 +181,7 @@ fn infer_mpn(path: &Path) -> Option<String> { } /// Public wrapper so other part-sources (e.g. the Adom Wiki puller) can record-/// provenance for files they copy in directly — the GLB and 3D-outline SVGs that+/// provenance for files they copy in directly, the GLB and 3D-outline SVGs that /// don't pass through `import_path` (which only ingests core CAD + datasheet). pub fn record_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> { write_file_provenance(dest, filename, prov)@@ -249,8 +248,8 @@ pub fn unprovenanced_files(dir: &Path, mpn: &str) -> Vec<String> { /// Backfill provenance for files that landed without going through import (the /// B2B-XH-A "heartbeat stub" case). Derives each present-but-un-provenanced-/// file's source from the strongest signal on info.json — `step_source` for the-/// STEP, else the `fetched_via_chain` entry that succeeded — and records it+/// file's source from the strongest signal on info.json, `step_source` for the+/// STEP, else the `fetched_via_chain` entry that succeeded, and records it /// (tagged `derived: true`). When nothing is recoverable it writes /// `discovery_source: "unknown"` so the gate keeps flagging it for a refetch /// instead of silently passing. Returns (recovered, still_unknown) filenames.@@ -268,7 +267,7 @@ pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> }) }); let step_src = info.get("step_source").and_then(|v| v.as_str())- .filter(|s| !s.is_empty() && *s != "—" && s.to_lowercase() != "none").map(String::from);+ .filter(|s| !s.is_empty() && *s != "," && s.to_lowercase() != "none").map(String::from); let path = dir.join(format!("{mpn}-file-provenance.json")); let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {@@ -285,7 +284,7 @@ pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> // that fetch, so re-attribute it to a manufacturer-tagged sibling (the STEP). // John: "how could a pdf source come from std kicad library? i ONLY want the // pdf from the manufacturer website." We do NOT touch aggregator-tagged PDFs- // (CSE / SnapEDA / Mouser) — those genuinely host datasheets, so relabeling+ // (CSE / SnapEDA / Mouser), those genuinely host datasheets, so relabeling // them "manufacturer" would be a lie; the UI honestly flags them instead. let impossible_for_datasheet = |s: &str| matches!(s.to_lowercase().as_str(), "kicad" | "kicad-library" | "kicad_community");@@ -304,7 +303,7 @@ pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> let disc = entry.get("discovery_source").and_then(|v| v.as_str()).unwrap_or(""); let orig = entry.get("content_origin").and_then(|v| v.as_str()).unwrap_or(""); // Only act on the impossible case, and only when we have a genuine- // manufacturer-direct sibling to copy — otherwise leave it truthful.+ // manufacturer-direct sibling to copy, otherwise leave it truthful. if (impossible_for_datasheet(disc) || impossible_for_datasheet(orig)) && mfr_sibling.is_some() { let m = mfr_sibling.as_ref().unwrap(); map.insert(pdf_name.clone(), serde_json::json!({@@ -318,7 +317,7 @@ pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> } } - // Only fill files that have NO entry at all — so this is idempotent and safe+ // Only fill files that have NO entry at all, so this is idempotent and safe // to run on every scan. (A file already recorded "unknown" keeps failing the // gate but is not re-written each time.) let to_fill: Vec<String> = core_files(mpn).into_iter()@@ -348,7 +347,7 @@ fn looks_like_mpn(s: &str) -> bool { has_alpha && has_digit && len_ok } -/// Datasheet PDF quality — the programmatic guarantee that we have a REAL+/// Datasheet PDF quality, the programmatic guarantee that we have a REAL /// datasheet, not a 1-page denial/cover page. Shells to pdfinfo (page count) and /// pdftotext (pinout text). Both ship in the container. pub struct PdfQuality {@@ -376,23 +375,23 @@ pub fn pdf_quality(file: &Path) -> PdfQuality { /// Is this chip's datasheet GOOD ENOUGH for ds2sf to extract from? (multi-page + /// a pinout). Used by the gate + the fetch flow to guarantee a usable datasheet-/// before — and after — running ds2sf. Returns Err(reason) when it isn't.+/// before, and after, running ds2sf. Returns Err(reason) when it isn't. pub fn datasheet_ready(pdf: &Path) -> std::result::Result<PdfQuality, String> { if !pdf.is_file() { return Err("no datasheet PDF present".into()); } let q = pdf_quality(pdf); if q.pages < 3 {- return Err(format!("datasheet is only {} page(s) — a CDN cover/denial page, not the real datasheet", q.pages));+ return Err(format!("datasheet is only {} page(s), a CDN cover/denial page, not the real datasheet", q.pages)); } if !q.has_pinout {- return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table — ds2sf can't extract a symbol from it", q.pages));+ return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table, ds2sf can't extract a symbol from it", q.pages)); } Ok(q) } /// Magic-byte validation. PDFs and STEPs both have unambiguous magic-/// strings — checking the first few bytes is the cheapest defense+/// strings, checking the first few bytes is the cheapest defense /// against "naked curl returned a 174 KB Cloudflare denial page that /// looks like 200 OK." Returns Err if the bytes don't match the claimed /// extension. KiCad sym/mod and WRL are text-format with looser headers,@@ -417,13 +416,13 @@ fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> { } // QUALITY GATE (learned the hard way 2026-06-23): %PDF magic alone is // NOT enough. A 1-page %PDF is almost always a CDN cover/denial page or- // a selection guide — NOT the datasheet — and it silently clobbered the+ // a selection guide, NOT the datasheet, and it silently clobbered the // real multi-page datasheets, breaking ds2sf. A genuine chip datasheet // is many pages. Reject anything < 3 pages outright. let q = pdf_quality(file); if q.pages > 0 && q.pages < 3 { return Err(anyhow!(- "PDF has only {} page(s) — not a real datasheet (likely a CDN cover/denial/selection page). \+ "PDF has only {} page(s), not a real datasheet (likely a CDN cover/denial/selection page). \ A genuine chip datasheet is many pages with a pinout. Refusing the import: {}", q.pages, file.display() ));@@ -449,7 +448,7 @@ fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> { return Err(anyhow!("not a real KiCad footprint: {}", file.display())); } }- // VRML and EDA formats — too many vendor variants to magic-check+ // VRML and EDA formats, too many vendor variants to magic-check // tightly; HTML check above is enough. _ => {} }@@ -459,7 +458,7 @@ fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> { /// Append a structured entry to `<MPN>-fetch-log.json` whenever a real /// file lands. Audit trail: timestamp, source filename, target name, /// size, magic-byte check pass. Survives across sessions; the AI doesn't-/// have to remember to write provenance — the binary writes it.+/// have to remember to write provenance, the binary writes it. fn append_fetch_log(dest: &Path, mpn: &str, target_name: &str, src: &Path) -> Result<()> { use std::time::{SystemTime, UNIX_EPOCH}; let log_path = dest.join(format!("{mpn}-fetch-log.json"));
src/inventory.rs+17−18@@ -13,8 +13,8 @@ pub struct Chip { pub has_mod: bool, // KiCad .kicad_mod pub has_altium_sch: bool, // Altium .SchLib pub has_altium_pcb: bool, // Altium .PcbLib- pub has_altium_intlib: bool, // Altium .IntLib (compiled — SchLib+PcbLib in one)- pub has_altium: bool, // (sch && pcb) || intlib — derived for the UI+ pub has_altium_intlib: bool, // Altium .IntLib (compiled, SchLib+PcbLib in one)+ pub has_altium: bool, // (sch && pcb) || intlib, derived for the UI pub altium_not_available: bool, // info.json says "not_available" (exhausted all sources) pub has_fusion_lbr: bool, // Fusion 360 .lbr pub fusion_not_available: bool, // info.json says "not_available" (exhausted all sources)@@ -22,10 +22,10 @@ pub struct Chip { pub pdf_count: usize, pub has_info: bool, pub has_thumb_symbol: bool,- pub sym_rev: u64, // mtime(ms) of <MPN>-symbol.svg — bumps when adom-symbol re-renders, so the dashboard reloads the thumbnail instantly+ pub sym_rev: u64, // mtime(ms) of <MPN>-symbol.svg, bumps when adom-symbol re-renders, so the dashboard reloads the thumbnail instantly pub has_thumb_footprint: bool, pub has_thumb_chip3d: bool, // Z-up iso (canonical, <MPN>-3d-iso.png)- pub has_thumb_chip3d_yup: bool, // Y-up iso (<MPN>-3d-iso-yup.png) — ME / Y-up source convention+ pub has_thumb_chip3d_yup: bool, // Y-up iso (<MPN>-3d-iso-yup.png), ME / Y-up source convention /// chip-thumbnailer's auto-inferred source-up axis: "z" or "y" (or "?" /// when the manifest is missing). Read from <MPN>-thumbs.json's /// thumbnails.threeD.inferredSourceUpAxis.@@ -66,7 +66,7 @@ pub struct Chip { pub pin1_measure_ok: Option<bool>, /// Which corner the FOOTPRINT's pin-1 sits in (NW/NE/SW/SE), derived from the /// `.kicad_mod` (pad "1" vs the pad centroid). This is the TARGET the 3D chip's- /// pin-1 must line up with — shown as a hint on unsigned cards, and compared+ /// pin-1 must line up with, shown as a hint on unsigned cards, and compared /// against `pin1_corner` to flag a chip-vs-footprint mismatch once signed. pub fp_pin1_corner: Option<String>, /// Per-file provenance: which ladder source + URL each library file@@ -96,7 +96,7 @@ pub struct Library { /// Which corner the footprint's pin-1 sits in (NW/NE/SW/SE), from the `.kicad_mod`. /// We take pad "1"'s position vs the centroid of all pads. KiCad footprint space /// is **Y-down** (negative Y = top/north). Returns None when there's no pad "1"-/// or pin-1 sits dead-centre (ambiguous). Pure string parse — no s-expr dep.+/// or pin-1 sits dead-centre (ambiguous). Pure string parse, no s-expr dep. fn footprint_pin1_corner(dir: &Path, mpn: &str) -> Option<String> { let content = std::fs::read_to_string(dir.join(format!("{mpn}.kicad_mod"))).ok()?; let mut p1: Option<(f64, f64)> = None;@@ -181,8 +181,7 @@ pub fn scan() -> anyhow::Result<Library> { let mpn = entry.file_name().to_string_lossy().to_string(); // ENFORCE provenance at the source: any file that landed without going // through import gets its origin recorded now (derived from info.json, or- // flagged "unknown"). Idempotent — only writes files with no entry yet —- // so a stray un-sourced file can never silently persist past one scan.+ // flagged "unknown"). Idempotent, only writes files with no entry yet, // so a stray un-sourced file can never silently persist past one scan. let _ = crate::import::backfill_dir(&path, &mpn); let chip = read_chip(&path, &mpn)?; total_size += chip.size_bytes;@@ -252,7 +251,7 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> { let family = info_value.get("family").and_then(|v| v.as_str()) .unwrap_or("").to_string(); let step_source = info_value.get("step_source").and_then(|v| v.as_str())- .unwrap_or("—").to_string();+ .unwrap_or("?").to_string(); // Thumbnails live alongside the source files using chip-thumbnailer's // canonical naming: <MPN>-symbol.svg / <MPN>-footprint.svg / <MPN>-3d-iso.png@@ -283,7 +282,7 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> { .map(String::from) .unwrap_or_else(|| inferred_up_axis.clone()); - // Altium — accept either the SchLib+PcbLib pair OR a compiled .IntLib (which+ // Altium, accept either the SchLib+PcbLib pair OR a compiled .IntLib (which // contains both internally; it's what Component Search Engine downloads ship). let has_altium = (has_altium_sch && has_altium_pcb) || has_altium_intlib; let altium_not_available = info_value.get("altium_source")@@ -292,7 +291,7 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> { .and_then(|v| v.as_str()) == Some("not_available"); // "Fully bundled" = all 3 EDA tools (KiCad sym+mod, Altium, Fusion lbr) + STEP.- // Datasheet PDF is intentionally not required — many chips ship with manufacturer+ // Datasheet PDF is intentionally not required, many chips ship with manufacturer // datasheets via separate fetch and shouldn't gate the bundled-ness signal. let fully_bundled = has_step && has_sym && has_mod@@ -359,7 +358,7 @@ fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> { .and_then(|v| v.get("measurements")).and_then(|m| m.get("passes")).and_then(|b| b.as_bool()); let fp_pin1_corner = footprint_pin1_corner(&dir, mpn); - // Per-file provenance map — chipsmith reads this for tooltips.+ // Per-file provenance map, chipsmith reads this for tooltips. let file_provenance = std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json"))) .ok() .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())@@ -476,7 +475,7 @@ fn source_tags( .and_then(|s| s.as_str()) .or_else(|| info.get("source_of_record").and_then(|s| s.get(sor_key)).and_then(|s| s.as_str())) // Don't FALSELY claim "Manufacturer (direct site)" when we never recorded- // a source — that misleading default is what made the carat look wrong+ // a source, that misleading default is what made the carat look wrong // ("Manufacturer direct site" on a chip we couldn't actually attribute). .unwrap_or("unknown") .to_string();@@ -490,16 +489,16 @@ fn source_tags( let src = crate::provenance::source_label(&canon_disc, manufacturer); let has = |f: String| dir.join(f).is_file(); let mut tags = Vec::new();- // KiCad library — the canonical render shown on the tile (current source).+ // KiCad library, the canonical render shown on the tile (current source). // When the source IS the KiCad standard library, don't say "KiCad library,- // from KiCad standard library" — the source already names the format.+ // from KiCad standard library", the source already names the format. if has_canonical { let kicad_label = if src.starts_with("KiCad") { src.clone() } else { format!("KiCad library, from {src}") }; tags.push(SourceTag { method: "kicad".into(), label: kicad_label, current: true, thumb: canon_thumb }); } // Altium library (.SchLib has the symbol/device, .PcbLib the footprint). // adom-sfconvert turns it into KiCad sym+mod via KiCad 10 (service-kicad),- // rendered to its own variant thumbnail — so the carat previews the Altium+ // rendered to its own variant thumbnail, so the carat previews the Altium // library's OWN geometry. let altium = if sor_key == "kicad_sym" { format!("{mpn_prefix}.SchLib") } else { format!("{mpn_prefix}.PcbLib") }; if has(altium) {@@ -508,12 +507,12 @@ fn source_tags( } // Fusion 360 / EAGLE library (.lbr holds both the device + the package). // adom-sfconvert turns it into KiCad sym+mod, rendered to its own variant- // thumbnail — so the carat previews the Fusion library's OWN geometry.+ // thumbnail, so the carat previews the Fusion library's OWN geometry. if has(format!("{mpn_prefix}.lbr")) { let fusion_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.fusion.svg")).is_file(); tags.push(SourceTag { method: "fusion".into(), label: format!("Fusion 360 library, from {src}"), current: false, thumb: fusion_thumb }); }- // The manufacturer DATASHEET, extracted by ds2sf — the independent check.+ // The manufacturer DATASHEET, extracted by ds2sf, the independent check. let ds2sf_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.ds2sf.svg")).is_file(); if dir.join(ds2sf_file).is_file() && ds2sf_thumb { let mfr = crate::provenance::source_label("manufacturer", manufacturer);
src/jobs.rsadded+160@@ -0,0 +1,160 @@+//! Background jobs with a live console, for the settings modal's Install /+//! Update rows (settings-layout: "silent multi-minute buttons are forbidden").+//! `POST /api/jobs {action}` starts one; `GET /api/jobs/<id>` streams its lines.++use serde_json::{json, Value};+use std::collections::HashMap;+use std::io::{BufRead, BufReader};+use std::process::{Command, Stdio};+use std::sync::{Arc, Mutex};++pub struct Job {+ pub id: u64,+ pub action: String,+ pub state: String, // running | done | failed+ pub exit_code: Option<i32>,+ pub lines: Vec<String>,+ pub started: u64,+ pub ended: Option<u64>,+}++static JOBS: Mutex<Option<HashMap<u64, Arc<Mutex<Job>>>>> = Mutex::new(None);+static NEXT_ID: Mutex<u64> = Mutex::new(1);++fn now() -> u64 {+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++fn registry() -> std::sync::MutexGuard<'static, Option<HashMap<u64, Arc<Mutex<Job>>>>> {+ let mut g = JOBS.lock().unwrap();+ if g.is_none() {+ *g = Some(HashMap::new());+ }+ g+}++/// The shell command for an action, or None when the action is unknown.+pub fn command_for(action: &str, body: &Value) -> Option<(String, Vec<String>)> {+ let sh = |script: &str| Some(("sh".to_string(), vec!["-c".to_string(), format!("export PATH=\"$HOME/.local/bin:$PATH\"; {script}")]));+ match action {+ "install_tools" => sh("adom-wiki pkg install adom/adom-chip-fetcher 2>&1; echo; adom-chip-fetcher doctor 2>&1"),+ "install_skill" => sh("adom-chip-fetcher install 2>&1"),+ "refresh_status" => sh("adom-chip-fetcher doctor 2>&1"),+ "import_logins" => {+ let hosts = body+ .get("hostFilter")+ .cloned()+ .unwrap_or_else(|| json!(crate::vault::DEFAULT_VENDOR_HOSTS));+ let args = json!({"hostFilter": hosts}).to_string();+ let thread = crate::bridge::ai_thread();+ let script = format!(+ "echo 'Scanning the browser profiles on your desktop (read only)...'; \+ adom-bridge --ai-thread {t} pup_import_scan '{{}}' 2>&1; echo; \+ echo 'Importing vendor logins into pup'\"'\"'s OS-keychain vault. Approve the Adom Bridge consent dialog and the Windows UAC prompt on your desktop.'; \+ adom-bridge --ai-thread {t} pup_import_browser_logins '{a}' 2>&1",+ t = shell_quote(&thread),+ a = args.replace('\'', "'\\''")+ );+ sh(&script)+ }+ _ => None,+ }+}++fn shell_quote(s: &str) -> String {+ format!("'{}'", s.replace('\'', "'\\''"))+}++pub fn start(action: &str, body: &Value) -> Result<u64, String> {+ let (prog, args) = command_for(action, body).ok_or_else(|| format!("unknown job action '{action}'"))?;+ let id = {+ let mut n = NEXT_ID.lock().unwrap();+ let id = *n;+ *n += 1;+ id+ };+ let job = Arc::new(Mutex::new(Job {+ id,+ action: action.to_string(),+ state: "running".into(),+ exit_code: None,+ lines: vec![format!("$ {}", args.last().cloned().unwrap_or_default())],+ started: now(),+ ended: None,+ }));+ registry().as_mut().unwrap().insert(id, job.clone());+ crate::dock::console_push("info", &format!("job {id} {action} started"), "jobs");+ std::thread::spawn(move || {+ let child = Command::new(&prog).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn();+ let mut child = match child {+ Ok(c) => c,+ Err(e) => {+ let mut j = job.lock().unwrap();+ j.lines.push(format!("ERROR: could not start: {e}"));+ j.state = "failed".into();+ j.ended = Some(now());+ return;+ }+ };+ let stdout = child.stdout.take();+ let stderr = child.stderr.take();+ let j2 = job.clone();+ let t_err = std::thread::spawn(move || {+ if let Some(e) = stderr {+ for line in BufReader::new(e).lines().map_while(Result::ok) {+ let mut j = j2.lock().unwrap();+ j.lines.push(line);+ if j.lines.len() > 2000 {+ j.lines.remove(0);+ }+ }+ }+ });+ if let Some(o) = stdout {+ for line in BufReader::new(o).lines().map_while(Result::ok) {+ let mut j = job.lock().unwrap();+ j.lines.push(line);+ if j.lines.len() > 2000 {+ j.lines.remove(0);+ }+ }+ }+ let _ = t_err.join();+ let status = child.wait();+ let mut j = job.lock().unwrap();+ let code = status.ok().and_then(|s| s.code());+ j.exit_code = code;+ j.state = if code == Some(0) { "done".into() } else { "failed".into() };+ j.ended = Some(now());+ j.lines.push(format!("[exit {}]", code.map(|c| c.to_string()).unwrap_or_else(|| "?".into())));+ crate::dock::console_push("info", &format!("job {} {} {}", j.id, j.action, j.state), "jobs");+ });+ Ok(id)+}++pub fn get_json(id: u64) -> Option<String> {+ let reg = registry();+ let job = reg.as_ref().unwrap().get(&id)?.clone();+ let j = job.lock().unwrap();+ Some(+ json!({+ "ok": true, "id": j.id, "action": j.action, "state": j.state, "exit_code": j.exit_code,+ "lines": j.lines, "started": j.started, "ended": j.ended,+ })+ .to_string(),+ )+}++pub fn list_json() -> String {+ let reg = registry();+ let items: Vec<Value> = reg+ .as_ref()+ .unwrap()+ .values()+ .map(|j| {+ let j = j.lock().unwrap();+ json!({"id": j.id, "action": j.action, "state": j.state, "started": j.started, "ended": j.ended})+ })+ .collect();+ json!({"ok": true, "jobs": items}).to_string()+}
src/library.rs+10−5@@ -9,11 +9,11 @@ pub struct Entry { pub has_pdf: bool, } +/// Where the per-part bundles live. Precedence: `CHIP_FETCHER_LIB`, the+/// source checkout's `library/` (the dev box), then the standard-folders+/// contract for every other user: `<workspace>/libraries/library/<MPN>/`+/// with the workspace at `/home/adom/project` (never next to the binary). pub fn root() -> PathBuf {- let here = std::env::current_exe()- .ok()- .and_then(|p| p.parent().map(|p| p.to_path_buf()))- .unwrap_or_else(|| PathBuf::from(".")); if let Some(env) = std::env::var_os("CHIP_FETCHER_LIB") { return PathBuf::from(env); }@@ -21,7 +21,12 @@ pub fn root() -> PathBuf { if candidate.exists() { return candidate; }- here.join("library")+ let workspace = std::env::var("ADOM_WORKSPACE")+ .map(PathBuf::from)+ .unwrap_or_else(|_| PathBuf::from("/home/adom/project"));+ let std_root = workspace.join("libraries").join("library");+ let _ = std::fs::create_dir_all(&std_root);+ std_root } pub fn ensure_dir(mpn: &str) -> Result<PathBuf> {
src/main.rs+6−1@@ -1,6 +1,11 @@ mod bom; mod cli;-mod creds;+mod bridge;+mod dock;+mod jobs;+mod settings;+mod taskbar;+mod vault; mod provenance; mod deliver; mod enrich;
src/provenance.rs+9−10@@ -1,13 +1,12 @@-//! Canonical source-provenance LABELING — the single rule for how every Adom+//! Canonical source-provenance LABELING, the single rule for how every Adom //! EDA app names where a symbol / footprint / 3D / datasheet actually came from. //!-//! THE RULE (John, 2026-06-23): label by the DISCOVERY SOURCE — where the file-//! was actually downloaded — NOT by content_origin (who authored the model).+//! THE RULE (John, 2026-06-23): label by the DISCOVERY SOURCE, where the file+//! was actually downloaded, NOT by content_origin (who authored the model). //! //! - ONLY the maker's OWN website counts as "the manufacturer", and then we //! NAME the site: "Texas Instruments (ti.com)", "Analog Devices (analog.com)".-//! - The moment the manufacturer hands you off to a 3rd-party library —-//! UltraLibrarian, SnapMagic/SnapEDA, Component Search Engine — that+//! - The moment the manufacturer hands you off to a 3rd-party library, //! UltraLibrarian, SnapMagic/SnapEDA, Component Search Engine, that //! AGGREGATOR is the source. NEVER label it "Manufacturer". //! - A fuzzy bare "Manufacturer" is misleading and banned. //!@@ -45,12 +44,12 @@ pub fn source_label(discovery: &str, manufacturer: &str) -> String { } else if !manufacturer.is_empty() { format!("{manufacturer} (direct)") } else {- // Don't claim "direct site" when we can't name the site — that's+ // Don't claim "direct site" when we can't name the site, that's // exactly the vague label John called out. Be honest instead. "manufacturer (site unrecorded)".to_string() } }- // 3rd-party aggregators — NEVER "the manufacturer".+ // 3rd-party aggregators, NEVER "the manufacturer". "cse" | "componentsearch" | "cse_ul" | "samacsys" => "Component Search Engine".into(), "snapeda" | "snapmagic" => "SnapEDA".into(), "ultralibrarian" | "ul" => "Ultra Librarian".into(),@@ -59,19 +58,19 @@ pub fn source_label(discovery: &str, manufacturer: &str) -> String { "arrow" => "Arrow".into(), "lcsc" => "LCSC".into(), "kicad_community" | "kicad-library" | "kicad" => "KiCad standard library".into(),- // The Adom Wiki — a published, Adom-vetted bundle reused as-is.+ // The Adom Wiki, a published, Adom-vetted bundle reused as-is. "adom-wiki" | "adom_wiki" | "adomwiki" => "Adom Wiki".into(), other => other.to_string(), } } /// A short note for the "the manufacturer linked you to it, but it's a 3rd-/// party" middle-ground — appended in tooltips so the user knows the maker+/// party" middle-ground, appended in tooltips so the user knows the maker /// pointed here even though the file came off an aggregator. pub fn linked_from_mfr_note(manufacturer: &str) -> String { if manufacturer.is_empty() { "the manufacturer's page linked to this 3rd-party library".to_string() } else {- format!("{manufacturer}'s own page links here, but the file is hosted by this 3rd-party library — not downloaded from {manufacturer} directly")+ format!("{manufacturer}'s own page links here, but the file is hosted by this 3rd-party library, not downloaded from {manufacturer} directly") } }
src/pup.rs+45−61@@ -3,12 +3,12 @@ //! adom-chip-fetcher drives the user's desktop browser to source CAD. There are two //! transports, and we DEFAULT to the first when it's available: //!-//! 1. **native-browser** (preferred) — the `adom-browser-extension` bridge+//! 1. **native-browser** (preferred), the `adom-browser-extension` bridge //! (`nbrowser_*` verbs). Drives the user's REAL signed-in Chrome/Edge, so //! their DigiKey/Mouser/SnapEDA logins, distributor pricing, and captcha //! human-trust all work. Several sites hard-block Puppeteer (DigiKey is the //! clearest) but serve the native session normally.-//! 2. **pup** (fallback) — Puppeteer (`browser_*` verbs), a cold profile where+//! 2. **pup** (fallback), Puppeteer (`browser_*` verbs), a cold profile where //! we have to recreate auth and can hit bot-walls. //! //! ISOLATION RULE (John, 2026-06-23): when using the native browser we open ONE@@ -24,7 +24,7 @@ use std::sync::OnceLock; /// Resolved transport: which verb family to use and which desktop to target. struct Transport {- /// Verb prefix: "nbrowser_" (native) or "browser_" (pup).+ /// Verb prefix: "nbrowser_" (native) or "pup_" (pup). prefix: &'static str, /// `--target <name>` to disambiguate when 2+ desktops are connected. `None` /// lets adom-desktop auto-route (fine for a single desktop).@@ -57,7 +57,7 @@ pub fn surface_label() -> String { fn detect_transport() -> Transport { // Explicit override: CHIP_FETCHER_BROWSER=native|pup match std::env::var("CHIP_FETCHER_BROWSER").ok().as_deref() {- Some("pup") => return Transport { prefix: "browser_", target: None, native: false },+ Some("pup") => return Transport { prefix: "pup_", target: None, native: false }, Some("native") => { let target = find_native().unwrap_or(None); return Transport { prefix: "nbrowser_", target, native: true };@@ -76,12 +76,12 @@ fn detect_transport() -> Transport { } None => { eprintln!(- "[adom-chip-fetcher] native-browser bridge not found — falling back to pup (cold profile). \+ "[adom-chip-fetcher] native-browser bridge not found, falling back to pup (cold profile). \ Install the Adom browser extension for your REAL logins + to skip bot-walls that \ block headless Chrome (DigiKey blocks it outright): \ https://wiki.adom.inc/adom/adom-browser-extension" );- Transport { prefix: "browser_", target: None, native: false }+ Transport { prefix: "pup_", target: None, native: false } } } }@@ -106,11 +106,13 @@ fn find_native() -> Option<Option<String>> { /// `(native_browser_running, connected_desktop_names)`. The names are non-empty /// only when the call came back `ambiguous_target` (2+ desktops). `bridge_list`-/// — unlike `ping` — includes `connected[]` in that error, so we read it here.+///, unlike `ping`, includes `connected[]` in that error, so we read it here. fn bridge_probe(target: Option<&str>) -> (bool, Vec<String>) {- let mut cmd = Command::new("adom-desktop");+ let mut cmd = crate::bridge::command(); if let Some(t) = target {- cmd.arg("--target").arg(t);+ if crate::bridge::target().as_deref() != Some(t) {+ cmd.arg("--target").arg(t);+ } } cmd.arg("bridge_list"); let v: Value = match cmd.output() {@@ -143,12 +145,11 @@ fn connected_from(v: &Value) -> Vec<String> { // ── public browser ops (transport-agnostic) ───────────────────────────────── -/// Open the session's ISOLATED window at `url` (first call) — every later+/// Open the session's ISOLATED window at `url` (first call), every later /// `open_tab` on the same session lands in this same window. On the native /// browser we drive the active signed-in profile, so the cold-profile `profile` /// arg is ignored there. pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> {- let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string()); let mut args = json!({ "sessionId": session_id, "url": url }); if !transport().native { args["profile"] = json!(profile);@@ -158,7 +159,6 @@ pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> /// Add a tab to the session's existing isolated window. pub fn open_tab(session_id: &str, url: &str) -> Result<Value> {- let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string()); call_browser("open_tab", &json!({ "sessionId": session_id, "url": url })) } @@ -200,7 +200,7 @@ pub fn session_has_url(session_id: &str, needle: &str) -> bool { .unwrap_or(false) } -/// Open `url` in the session's ONE isolated window — as a TAB when the window+/// Open `url` in the session's ONE isolated window, as a TAB when the window /// already exists (e.g. the dashboard is tab 1), else create the window. This /// is the rule for native browsing: every source lands as a tab beside the /// dashboard, never in a separate window and never touching the user's others.@@ -236,7 +236,7 @@ pub fn close_window(session_id: &str) -> Result<Value> { // ── credential awareness: adom-chip-fetcher reads the browser's saved-login store ── // across ALL profiles (Chrome + Edge), maps hosts to the sourcing ladder, and // auto-switches to the profile that's signed in to the vendor it's about to-// scrape — so the whole flow self-drives without asking the user anything.+// scrape, so the whole flow self-drives without asking the user anything. #[derive(Clone)] pub struct Cred {@@ -283,7 +283,7 @@ pub const VENDOR_HOSTS: &[(&str, &str)] = &[ /// Hosts whose downloads are authenticated by adom-chip-fetcher's OWN credential /// vault (an `Authorization: Basic` header on the XHR), NOT by the browser's /// saved cookie session. For these we must NOT switch the active browser to-/// wherever a saved login happens to live — the vault works in any browser, so+/// wherever a saved login happens to live, the vault works in any browser, so /// we stay on the user's preferred browser (Chrome). This is why John saw Edge: /// his CSE login is saved in Edge, so the auto-pick dragged the whole run there /// even though CSE never needed that browser login.@@ -294,7 +294,7 @@ pub const VAULT_AUTH_HOSTS: &[&str] = &["componentsearchengine", "samacsys"]; pub const PREFERRED_BROWSER: &str = "chrome"; /// If `url`'s host matches a ladder vendor, return the profile (email) that's-/// signed in to it — preferring the profile with the broadest vendor coverage,+/// signed in to it, preferring the profile with the broadest vendor coverage, /// so multi-vendor runs stay on one rich profile when possible. pub fn profile_for_url(url: &str) -> Option<String> { let lower = url.to_lowercase();@@ -382,8 +382,8 @@ pub fn ensure_work_window() -> Result<()> { return Ok(()); } // Drive the user's PREFERRED browser (Chrome). Switch the bridge's active- // profile to a Chrome profile before opening, so the work window — and every- // scrape tab in it — lives in Chrome, not whichever browser happens to hold+ // profile to a Chrome profile before opening, so the work window, and every+ // scrape tab in it, lives in Chrome, not whichever browser happens to hold // the most vendor logins. prefer_chrome_profile(); // No dashboard tab present → open the window WITH the dashboard as tab 1.@@ -419,7 +419,7 @@ pub fn prefer_chrome_profile() { } } -/// The dedicated scrape tab in the work window — reuse the stored one if it's+/// The dedicated scrape tab in the work window, reuse the stored one if it's /// still alive, else open a fresh tab (beside the dashboard) and remember it. pub fn ensure_scrape_tab() -> Result<String> { ensure_work_window()?; // dashboard is always tab 1 before any scrape tab@@ -441,10 +441,9 @@ pub fn ensure_scrape_tab() -> Result<String> { /// Navigate the dedicated scrape tab (native) or the pup scrape window (fallback). pub fn scrape_navigate(url: &str) -> Result<Value> {- let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string()); if is_native() { // Self-drive: if this is a known vendor, switch to the profile that's- // signed in to it before loading the page — no questions asked.+ // signed in to it before loading the page, no questions asked. if let Some(prof) = profile_for_url(&url) { if use_profile(&prof).is_ok() { eprintln!("[adom-chip-fetcher] using your signed-in profile '{prof}' for this vendor");@@ -480,10 +479,9 @@ pub fn scrape_eval(expr: &str) -> Result<Value> { /// Fetch a URL's BYTES through the user's signed-in browser (real cookies, so /// login-gated CAD works too) and write them to `dest`. No download, no native-/// viewer, no extra window — the bytes come back base64 in one call. Returns+/// viewer, no extra window, the bytes come back base64 in one call. Returns /// (bytes_written, content_type). pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, String)> {- let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string()); let v = call_browser("fetch_url", &json!({ "sessionId": WORK_SESSION, "url": url }))?; let out = v.get("output").and_then(|o| o.as_str()) .ok_or_else(|| anyhow::anyhow!("fetch_url: no output ({v})"))?;@@ -502,42 +500,26 @@ pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, St Ok((bytes.len(), ctype)) } -/// Base64-encode (for the CSE basic-auth Authorization header).-fn b64_encode(data: &[u8]) -> String {- const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);- for ch in data.chunks(3) {- let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];- let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;- out.push(T[(n >> 18 & 63) as usize] as char);- out.push(T[(n >> 12 & 63) as usize] as char);- out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });- out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });- }- out-}--/// Fetch a Component Search Engine CAD zip for `mpn` via XHR + basic-auth.-/// XHR (unlike fetch()) accepts an Authorization header AND doesn't trigger a-/// browser download — so this bypasses BOTH the fetch() credential ban and the-/// browser's multiple-download block. The scrape tab is navigated to CSE first+/// Fetch a Component Search Engine CAD zip for `mpn` via an XHR chain.+/// XHR does not trigger a browser download, so this bypasses Chrome's+/// multiple-download block; pup's vault answers the Basic Auth challenge. The scrape tab is navigated to CSE first /// (same-origin). Chains entry → partID → zipForm → ga/model.php. Returns the /// raw zip bytes (`.step` + `.kicad_sym` + `.kicad_mod`).-pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec<u8>> {- // Navigate CLEAN (no inline-credential wrapping) — auth is the XHR header,- // and an inline-cred URL breaks the page + leaks the password into the URL bar.+pub fn cse_fetch_zip(mpn: &str, mfr: &str) -> Result<Vec<u8>> {+ // Navigate CLEAN. CSE is HTTP Basic Auth; pup applies the vault credential+ // for *.componentsearchengine.com via page.authenticate() on every navigate,+ // which also answers the XHR chain below. No header, no user:pass@ URL. let tab = ensure_scrape_tab()?; call_browser("navigate", &json!({ "sessionId": WORK_SESSION, "tabId": tab, "url": "https://mouser.componentsearchengine.com/" }))?; std::thread::sleep(std::time::Duration::from_secs(4));- let auth = b64_encode(format!("{user}:{pass}").as_bytes()); let mpn_js = serde_json::to_string(mpn).unwrap(); let mfr_js = serde_json::to_string(mfr).unwrap(); let expr = format!(- "(function(){{return new Promise(function(res){{var A='Basic {auth}';var MPN={mpn_js};var MFR={mfr_js};\- function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);x.setRequestHeader('Authorization',A);\+ "(function(){{return new Promise(function(res){{var MPN={mpn_js};var MFR={mfr_js};\+ function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);\ if(ty)x.responseType=ty;x.onload=function(){{cb(x)}};x.onerror=function(){{res(JSON.stringify({{err:'xhr'}}))}};x.send()}}\ var base='https://mouser.componentsearchengine.com/';\ xhr(base+'entry_u_newDesign.php?mna='+encodeURIComponent(MFR)+'&mpn='+encodeURIComponent(MPN)+'&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB',null,function(a){{\@@ -548,7 +530,7 @@ pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec xhr(base+'ga/model.php?'+q,'arraybuffer',function(c){{var u=new Uint8Array(c.response);var s='';\ for(var i=0;i<u.length;i+=8192)s+=String.fromCharCode.apply(null,u.subarray(i,i+8192));\ res(JSON.stringify({{head:[u[0],u[1],u[2],u[3]],len:u.length,b64:btoa(s)}}))}})}})}})}})}})()",- auth = auth, mpn_js = mpn_js, mfr_js = mfr_js+ mpn_js = mpn_js, mfr_js = mfr_js ); // Cold CSE calls flake (the entry XHR fires before the session cookie // fully settles → spurious "no-partID"). Retry the whole chain a few@@ -611,7 +593,7 @@ pub fn scrape_screenshot() -> Result<Value> { } /// Pull a file from the user's desktop into a local container directory.-/// Desktop-level verb (not browser-scoped) — same on both transports.+/// Desktop-level verb (not browser-scoped), same on both transports. pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> { let v = call_desktop("pull_file", &json!({ "filePaths": [remote_path], "saveTo": save_to }))?; let local = v["files"][0]["path"]@@ -630,7 +612,7 @@ pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Va // ── call plumbing ──────────────────────────────────────────────────────────── -/// Call a browser verb — prefixes with the active transport's verb family and+/// Call a browser verb, prefixes with the active transport's verb family and /// injects `--target` when one is resolved. fn call_browser(suffix: &str, args: &Value) -> Result<Value> { let t = transport();@@ -655,45 +637,47 @@ fn call_with_recovery( target: Option<&str>, allow_recovery: bool, ) -> Result<Value> {- let mut cmd = Command::new("adom-desktop");+ let mut cmd = crate::bridge::command(); if let Some(t) = target {- cmd.arg("--target").arg(t);+ if crate::bridge::target().as_deref() != Some(t) {+ cmd.arg("--target").arg(t);+ } } cmd.arg(action).arg(args.to_string()); let out = cmd .output()- .with_context(|| format!("running adom-desktop {action}"))?;+ .with_context(|| format!("running adom-bridge {action}"))?; if !out.status.success() { anyhow::bail!(- "adom-desktop {action} failed: {}",+ "adom-bridge {action} failed: {}", String::from_utf8_lossy(&out.stderr) ); } let v: Value = serde_json::from_slice(&out.stdout)- .with_context(|| format!("parsing adom-desktop {action} stdout"))?;+ .with_context(|| format!("parsing adom-bridge {action} stdout"))?; if !allow_recovery { return Ok(v); } - // Recovery 1: ambiguous target — pick a concrete desktop and retry.+ // Recovery 1: ambiguous target, pick a concrete desktop and retry. if target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") { let pick = find_native() .and_then(|t| t) .or_else(|| connected_from(&v).into_iter().next()); if let Some(name) = pick {- println!("WARN: adom-desktop {action} ambiguous_target; retrying on --target {name}");+ println!("WARN: adom-bridge {action} ambiguous_target; retrying on --target {name}"); return call_with_recovery(action, args, Some(&name), false); } } - // Recovery 2: session lost — rescan, then retry once if it came back.+ // Recovery 2: session lost, rescan, then retry once if it came back. if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) { if code == "session_not_found" || code == "session_disconnected" { let sess = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");- println!("WARN: adom-desktop {action} returned {code} for session {sess:?}; running rescan");+ println!("WARN: adom-bridge {action} returned {code} for session {sess:?}; running rescan"); let prefix = transport().prefix;- let mut rescan = Command::new("adom-desktop");+ let mut rescan = crate::bridge::command(); if let Some(t) = target { rescan.arg("--target").arg(t); }
src/serve.rs+345−68@@ -5,7 +5,7 @@ use std::sync::Mutex; use tiny_http::{Header, Method, Response, Server}; const INDEX_HTML: &str = include_str!("ui/index.html");-// Architecture / dependency map — a first-class in-app view so anyone editing+// Architecture / dependency map, a first-class in-app view so anyone editing // chip-fetcher can see the whole tool graph (local CLIs, native libs, remote // services, sub-apps) and understand what else they may need to modify. const TOOL_MAP_HTML: &str = include_str!("../tool-map.html");@@ -25,11 +25,59 @@ fn now_epoch() -> u64 { .unwrap_or(0) } -pub fn run(port: u16) -> Result<()> {+/// Serve the dashboard. `print_url`: the Dock Bar contract (print the+/// direct URL, self-background, exit 0). `detached_child`: we ARE the+/// background copy spawned by a `--print-url` parent.+pub fn run(port: u16, print_url: bool, detached_child: bool) -> Result<()> {+ use crate::dock;+ if !detached_child {+ // Idempotent + version-aware: an instance of OURS makes relaunch+ // instant; a different version is the thing to replace.+ if let Some(their) = dock::running_version(port) {+ if their != dock::VERSION {+ if !dock::retire_stale(port, &their) {+ anyhow::bail!(+ "a stale adom-chip-fetcher {their} owns port {port} and would not exit.\n Hint: find it with `pgrep -af 'adom-chip-fetcher serve'` and kill it, then relaunch."+ );+ }+ } else {+ if print_url {+ println!("{}", dock::direct_url(port));+ } else {+ println!("OK: adom-chip-fetcher {} already serving on {}", dock::VERSION, dock::direct_url(port));+ }+ dock::claim_dock_webview(port);+ return Ok(());+ }+ }+ if print_url {+ // Spawn a detached copy, wait for /health, print, exit 0. The dock+ // runs this step synchronously; a foreground server would be killed+ // at its timeout.+ let url = dock::spawn_detached_server(port)?;+ println!("{url}");+ return Ok(());+ }+ } let addr = format!("0.0.0.0:{port}");- let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;- println!("adom-chip-fetcher viewer listening on http://{addr}");- println!("(open in Hydrogen via `adom-chip-fetcher app`, or visit in any browser)");+ let server = match Server::http(&addr) {+ Ok(s) => s,+ Err(e) => {+ // A pre-/health build of ours may own the port: retire by exact argv, retry once.+ if dock::running_version(port).is_none() && dock::retire_stale(port, "pre-1.3") {+ Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?+ } else {+ anyhow::bail!("bind {addr}: {e}\n Hint: something else owns port {port}; pick another with --port.");+ }+ }+ };+ dock::console_push("info", &format!("adom-chip-fetcher {} serving on {}", dock::VERSION, dock::direct_url(port)), "serve");+ if !detached_child {+ println!("adom-chip-fetcher viewer listening on http://{addr}");+ println!("(open in pup via `adom-chip-fetcher app`, or visit in any browser)");+ }+ dock::claim_dock_webview(port);+ LIBRARY_VERSION.lock().map(|mut v| *v = library_signature()).ok(); for mut request in server.incoming_requests() { let raw_url = request.url().to_string();@@ -41,8 +89,119 @@ pub fn run(port: u16) -> Result<()> { no_qs.to_string() }; let method = request.method().clone();+ let qs = raw_url.split('?').nth(1).unwrap_or("").to_string(); let response = match (&method, url.as_str()) {+ (Method::Options, _) => Response::from_string(String::new()).with_status_code(204), (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),+ (Method::Get, "/health") => json_response(crate::dock::health_json()),+ (Method::Get, "/api/status") => json_response(crate::dock::status_json(qs.contains("force=1"))),+ (Method::Get, "/state") => json_response(state_json()),+ (Method::Post, "/state/ui") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ ui_state_set(&body)+ }+ (Method::Get, "/console") => {+ let limit = qs.split('&').find_map(|p| p.strip_prefix("limit=")).and_then(|n| n.parse().ok()).unwrap_or(200);+ json_response(crate::dock::console_json(limit))+ }+ (Method::Post, "/console") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ let level = v.get("level").and_then(|l| l.as_str()).unwrap_or("log");+ let msg = v.get("msg").and_then(|m| m.as_str()).unwrap_or("");+ crate::dock::console_push(level, msg, "ui");+ json_response("{\"ok\":true}".into())+ }+ (Method::Get, "/api/say") => json_response(serde_json::json!({"ok": true, "phrases": crate::dock::say_phrases()}).to_string()),+ (Method::Post, "/api/say/try") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ let phrase = v.get("phrase").and_then(|p| p.as_str()).unwrap_or("");+ if phrase.is_empty() {+ error_response(400, "{\"ok\":false,\"error\":\"phrase required\"}".into())+ } else {+ json_response(crate::dock::try_phrase(phrase).to_string())+ }+ }+ (Method::Get, "/api/settings") => json_response(crate::settings::describe_json()),+ (Method::Post, "/api/settings") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ match crate::settings::merge(&v) {+ Ok(m) => json_response(serde_json::json!({"ok": true, "settings": m}).to_string()),+ Err(e) => error_response(400, serde_json::json!({"ok": false, "error": e.to_string()}).to_string()),+ }+ }+ (Method::Post, "/api/settings/reset") => match crate::settings::reset() {+ Ok(m) => json_response(serde_json::json!({"ok": true, "settings": m}).to_string()),+ Err(e) => error_response(500, serde_json::json!({"ok": false, "error": e.to_string()}).to_string()),+ },+ (Method::Post, "/api/jobs") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("");+ match crate::jobs::start(action, &v) {+ Ok(id) => json_response(serde_json::json!({"ok": true, "id": id}).to_string()),+ Err(e) => error_response(400, serde_json::json!({"ok": false, "error": e}).to_string()),+ }+ }+ (Method::Get, "/api/jobs") => json_response(crate::jobs::list_json()),+ (Method::Get, path) if path.starts_with("/api/jobs/") => {+ let id: u64 = path.trim_start_matches("/api/jobs/").parse().unwrap_or(0);+ match crate::jobs::get_json(id) {+ Some(j) => json_response(j),+ None => not_found(),+ }+ }+ (Method::Post, "/api/credentials/import") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ match crate::jobs::start("import_logins", &v) {+ Ok(id) => json_response(serde_json::json!({"ok": true, "id": id}).to_string()),+ Err(e) => error_response(400, serde_json::json!({"ok": false, "error": e}).to_string()),+ }+ }+ (Method::Post, "/api/login") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ let source = v.get("source").and_then(|a| a.as_str()).unwrap_or("").to_string();+ std::thread::spawn(move || {+ let _ = crate::cli::login_source(&source, None, None);+ });+ json_response(serde_json::json!({"ok": true, "started": true, "source": v.get("source")}).to_string())+ }+ (Method::Post, "/ui/toast") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("");+ let kind = v.get("kind").and_then(|k| k.as_str()).unwrap_or("info");+ crate::dock::toast_push(msg, kind);+ json_response("{\"ok\":true}".into())+ }+ (Method::Post, "/ui/command") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ match serde_json::from_str::<serde_json::Value>(&body) {+ Ok(v) if v.get("cmd").is_some() => {+ crate::dock::ui_command_push(v);+ json_response("{\"ok\":true}".into())+ }+ _ => error_response(400, "{\"ok\":false,\"error\":\"need {cmd:...}\"}".into()),+ }+ }+ (Method::Get, "/api/durations") => json_response(crate::taskbar::durations_json()),+ (Method::Get, path) if path.starts_with("/assets/") || path.contains("/assets/") => {+ let rel = path[path.find("/assets/").unwrap() + 8..].to_string();+ serve_ui_asset(&rel)+ } (Method::Get, "/architecture") | (Method::Get, "/tool-map") => html_response(TOOL_MAP_HTML), (Method::Get, "/api/library") => library_json(), (Method::Get, "/api/sources") => sources_json(),@@ -126,11 +285,138 @@ pub fn run(port: u16) -> Result<()> { } _ => not_found(), };- let _ = request.respond(response);+ let cors = Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();+ let cors_m = Header::from_bytes(&b"Access-Control-Allow-Methods"[..], &b"GET, POST, DELETE, OPTIONS"[..]).unwrap();+ let cors_h = Header::from_bytes(&b"Access-Control-Allow-Headers"[..], &b"Content-Type, X-Adom-Caller-Thread"[..]).unwrap();+ let _ = request.respond(response.with_header(cors).with_header(cors_m).with_header(cors_h)); } Ok(()) } +// ---- live-state helpers (docs/dashboard-api-contract.md) ----++static LIBRARY_VERSION: Mutex<u64> = Mutex::new(0);+static UI_STATE: Mutex<Option<serde_json::Value>> = Mutex::new(None);++/// A cheap signature of library/: the count + newest mtime of every part dir.+fn library_signature() -> u64 {+ let root = crate::library::root();+ let mut sig: u64 = 0;+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ if let Ok(md) = e.metadata() {+ if md.is_dir() {+ let m = md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0);+ sig = sig.wrapping_mul(31).wrapping_add(m).wrapping_add(1);+ }+ }+ }+ }+ sig+}++fn state_version() -> u64 {+ let sig = library_signature();+ if let Ok(mut v) = LIBRARY_VERSION.lock() {+ if *v != sig {+ *v = sig;+ }+ return *v;+ }+ sig+}++fn ui_state_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ match serde_json::from_str::<serde_json::Value>(body) {+ Ok(mut v) => {+ if let Some(o) = v.as_object_mut() {+ o.insert("ui_reported_at".into(), serde_json::json!(now_epoch()));+ }+ if let Ok(mut s) = UI_STATE.lock() {+ *s = Some(v);+ }+ json_response("{\"ok\":true}".into())+ }+ Err(e) => error_response(400, format!("{{\"ok\":false,\"error\":\"invalid json: {e}\"}}")),+ }+}++fn state_json() -> String {+ let activity: serde_json::Value = {+ let entry = ACTIVITY.lock().ok().and_then(|v| v.clone());+ match entry {+ Some((body, ts)) => {+ let mut v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+ if let Some(o) = v.as_object_mut() {+ o.insert("_age_seconds".into(), serde_json::json!(now_epoch().saturating_sub(ts)));+ }+ v+ }+ None => serde_json::json!({}),+ }+ };+ let ui = UI_STATE.lock().ok().and_then(|s| s.clone()).unwrap_or_else(|| serde_json::json!({}));+ let (parts, complete) = {+ let root = crate::library::root();+ let mut parts = 0;+ let mut complete = 0;+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ let p = e.path();+ if !p.is_dir() { continue; }+ let mpn = e.file_name().to_string_lossy().to_string();+ if mpn.starts_with('.') { continue; }+ parts += 1;+ let has = |ext: &str| p.join(format!("{mpn}.{ext}")).is_file();+ if has("pdf") && has("step") && has("kicad_sym") && has("kicad_mod") { complete += 1; }+ }+ }+ (parts, complete)+ };+ serde_json::json!({+ "ok": true,+ "app": crate::dock::APP,+ "version": crate::dock::VERSION,+ "activity": activity,+ "library_count": parts,+ "complete_count": complete,+ "active_project": crate::projects::get_active(),+ "selected_mpn": ui.get("selected_mpn").cloned().unwrap_or(serde_json::Value::Null),+ "view": ui.get("view").cloned().unwrap_or(serde_json::json!("grid")),+ "settings_open": ui.get("settings_open").cloned().unwrap_or(serde_json::json!(false)),+ "zoom_mpn": ui.get("zoom_mpn").cloned().unwrap_or(serde_json::Value::Null),+ "ui_reported_at": ui.get("ui_reported_at").cloned().unwrap_or(serde_json::Value::Null),+ "state_version": state_version(),+ "settings": crate::settings::load(),+ "_hint": "POST /ui/toast {message,kind} raises a toast; POST /ui/command {cmd:click|select|view|open_settings|close_settings|zoom|unzoom|reload,...} drives the page; GET /console reads the UI log.",+ })+ .to_string()+}++/// Static files for the page (settings screenshots, the shared cog). Served+/// by suffix so the proxied sub-path works too. Looks in <repo>/src/ui/assets+/// next to the source, then ~/.local/share/adom-chip-fetcher/assets.+fn serve_ui_asset(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ if rel.contains("..") { return not_found(); }+ let home = std::env::var("HOME").unwrap_or_default();+ let candidates = [+ PathBuf::from("/home/adom/project/chip-fetcher/src/ui/assets").join(rel),+ PathBuf::from(&home).join(".local/share/adom-chip-fetcher/assets").join(rel),+ ];+ for p in candidates.iter() {+ if let Ok(bytes) = std::fs::read(p) {+ let ctype = match p.extension().and_then(|e| e.to_str()).unwrap_or("") {+ "png" => "image/png", "svg" => "image/svg+xml", "jpg" | "jpeg" => "image/jpeg",+ "webp" => "image/webp", "css" => "text/css", "js" => "application/javascript",+ "woff2" => "font/woff2", _ => "application/octet-stream",+ };+ let h = Header::from_bytes(&b"Content-Type"[..], ctype.as_bytes()).unwrap();+ return Response::from_data(bytes).with_header(h);+ }+ }+ not_found()+}+ fn activity_get() -> Response<std::io::Cursor<Vec<u8>>> { let entry = ACTIVITY.lock().ok().and_then(|v| v.clone()); let json = match entry {@@ -149,7 +435,15 @@ fn activity_get() -> Response<std::io::Cursor<Vec<u8>>> { } None => "{}".to_string(), };- json_response(json)+ // Live channel extras: drained toasts + UI commands, and the library+ // signature so the page refreshes the grid only when something changed.+ let mut v: serde_json::Value = serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({}));+ if let Some(o) = v.as_object_mut() {+ o.insert("_toasts".into(), serde_json::Value::Array(crate::dock::drain_toasts()));+ o.insert("_ui".into(), serde_json::Value::Array(crate::dock::drain_ui_commands()));+ o.insert("_state_version".into(), serde_json::json!(state_version()));+ }+ json_response(v.to_string()) } fn activity_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {@@ -163,12 +457,9 @@ fn activity_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { } fn credentials_list() -> Response<std::io::Cursor<Vec<u8>>> {- match crate::creds::list_public() {- Ok(creds) => {- let body = serde_json::json!({"credentials": creds}).to_string();- json_response(body)- }- Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ match crate::vault::list() {+ Ok(v) => json_response(v.to_string()),+ Err(e) => error_response(500, format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string())), } } @@ -183,29 +474,28 @@ fn credentials_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { if host.is_empty() || username.is_empty() || password.is_empty() { return error_response(400, "{\"error\":\"host, username, password all required\"}".to_string()); }- match crate::creds::set(host, username, password) {- Ok(_) => json_response("{\"ok\":true}".to_string()),- Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ match crate::vault::set(host, username, password) {+ Ok(v) => json_response(v.to_string()),+ Err(e) => error_response(500, format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string())), } } fn credentials_delete(host: &str) -> Response<std::io::Cursor<Vec<u8>>> { let host = urldecode(host);- match crate::creds::delete(&host) {- Ok(removed) => json_response(format!("{{\"ok\":true,\"removed\":{removed}}}")),- Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ match crate::vault::delete(&host) {+ Ok(v) => json_response(v.to_string()),+ Err(e) => error_response(500, format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string())), } } // POST /api/open {"mpn":"<X>","kind":"chip3d|symbol|footprint|datasheet"}-// All four open in a NEW PUP TAB at a publicly-routable proxy URL —-// this matters because pup runs on the user's Windows machine, so a+// All four open in a NEW PUP TAB at a publicly-routable proxy URL, // this matters because pup runs on the user's Windows machine, so a // loopback URL (127.0.0.1) on the container is unreachable from there. // // We resolve the proxy URL from $ADOM_PROXY_HOST or $ADOM_CONTAINER_SLUG // (set by `chip-fetcher dashboard ensure`). When neither is set we fall // back to 127.0.0.1 so dev-running-on-localhost still works.-/// POST /api/deliver {mpn, target} — send the chip's CAD to a desktop EDA tool.+/// POST /api/deliver {mpn, target}, send the chip's CAD to a desktop EDA tool. fn deliver_chip(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { let parsed: serde_json::Value = match serde_json::from_str(body) { Ok(v) => v,@@ -263,7 +553,7 @@ fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { // wrestling adom-step's pose. Click "Z-up" thumb for the live // interactive 3D viewer. if !dir.join(format!("{mpn}-3d-iso-yup-lg.png")).exists() {- return error_response(404, format!("{{\"error\":\"no {mpn}-3d-iso-yup-lg.png — run `chip-thumbnailer scan`\"}}"));+ return error_response(404, format!("{{\"error\":\"no {mpn}-3d-iso-yup-lg.png, run `chip-thumbnailer scan`\"}}")); } format!("{base}/thumb/{mpn}/chip3d-yup-lg") }@@ -275,7 +565,7 @@ fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { // Real adom-step viewer integration: // 1. Ensure server on :8851 (spawn detached if not running). // 2. POST the STEP bytes to /api/load so the server switches- // to this chip — adom-step is single-file-per-server.+ // to this chip, adom-step is single-file-per-server. // 3. Open the public proxy URL of port 8851 in a new pup tab. ensure_adom_step_server(); if !load_step_into_adom_step(&step, mpn) {@@ -311,7 +601,7 @@ fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { } // Launch the standalone adom-symbol live app (no gallia, no Node): // 1. Ensure `adom-symbol serve` is up on its port.- // 2. POST the raw .kicad_sym to /load — the app renders it+ // 2. POST the raw .kicad_sym to /load, the app renders it // server-side via service-kicad and updates its state. // 3. Open the app's public proxy URL in a pup tab. ensure_adom_symbol_server();@@ -359,8 +649,7 @@ fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { _ => return error_response(400, format!("{{\"error\":\"unknown kind {:?} (want chip3d|symbol|footprint|lbr|datasheet)\"}}", kind)), }; - // Return the app's public URL and let the CLIENT open it with window.open —- // that opens a tab in whatever browser the user is actually viewing+ // Return the app's public URL and let the CLIENT open it with window.open, // that opens a tab in whatever browser the user is actually viewing // chip-fetcher in (their phone, a pup window, a Hydrogen webview), which is // the only universally-correct surface. (The old server-side // `adom-desktop browser_open_tab` always opened on the LAPTOP, so a phone@@ -374,7 +663,7 @@ fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { /// Whether an Adom Desktop client is connected to this container's bridge. /// Cached briefly (the probe shells out). Used only to label the open method +-/// gate Send-to — the actual app open happens client-side via window.open.+/// gate Send-to, the actual app open happens client-side via window.open. fn desktop_bridge_available() -> bool { use std::sync::Mutex; use std::time::{Duration, Instant};@@ -384,24 +673,13 @@ fn desktop_bridge_available() -> bool { return v; } }- let avail = std::process::Command::new("timeout")- .args(["3", "adom-desktop", "status"])- .output()- .ok()- .map(|o| {- serde_json::from_str::<serde_json::Value>(&String::from_utf8_lossy(&o.stdout))- .ok()- .and_then(|v| v.get("clients").and_then(|c| c.as_u64()))- .map(|n| n > 0)- .unwrap_or(false)- })- .unwrap_or(false);+ let avail = !crate::bridge::connected_targets().is_empty(); *CACHE.lock().unwrap() = Some((Instant::now(), avail)); avail } /// Fallback open (e.g. raw SVG when the live app load failed): return the URL for-/// the client to window.open — same client-side strategy as the main path.+/// the client to window.open, same client-side strategy as the main path. fn open_pup_tab(url: &str, _label: &str) -> Response<std::io::Cursor<Vec<u8>>> { json_response(format!("{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}", url, desktop_bridge_available())) }@@ -427,7 +705,7 @@ fn port_listening(port: u16) -> bool { fn ensure_adom_step_server() { if port_listening(ADOM_STEP_PORT) { return; } // Need a placeholder file to start the server. We'll use any STEP from- // the library — the next /api/load POST will replace it. If the library+ // the library, the next /api/load POST will replace it. If the library // is empty, skip; the load call below will surface the error. let any_step = std::fs::read_dir(crate::library::root()) .ok()@@ -483,7 +761,7 @@ fn write_tmp_file(bytes: &[u8]) -> Option<std::path::PathBuf> { } /// Generate (or refresh) <MPN>-symbol-viewer.html via the local Node-/// helper. Idempotent at the script level — caller checks file existence+/// helper. Idempotent at the script level, caller checks file existence /// first and only invokes this on first click. Returns true on success. // POST /api/orientation {"mpn":"<X>","axis":"z"|"y"} // Records the user's choice of up-axis for this chip. Persists to@@ -493,7 +771,7 @@ fn write_tmp_file(bytes: &[u8]) -> Option<std::path::PathBuf> { // has to pick once per chip. // // When the user picks the SAME axis as chip-thumbnailer's inferred axis,-// we still record it explicitly — that way "user has confirmed" is+// we still record it explicitly, that way "user has confirmed" is // visible in the data, vs. "still relying on inference." fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { let parsed: serde_json::Value = match serde_json::from_str(body) {@@ -541,7 +819,7 @@ fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> { // path re-renders just the vector outline (one OCCT pass, no shaded-iso // family), then we recomposite the canonical symbol so its embedded 3D chip // matches the orientation the user just picked. Off-thread so the click- // returns instantly — the dashboard's poll picks up the new SVG.+ // returns instantly, the dashboard's poll picks up the new SVG. { let mpn = mpn.to_string(); std::thread::spawn(move || {@@ -718,7 +996,7 @@ fn pup_visible_base_url() -> String { proxy_url_for_port(8786).trim_end_matches('/').to_string() } -/// Same logic as pup_visible_base_url but for an arbitrary port — used so+/// Same logic as pup_visible_base_url but for an arbitrary port, used so /// adom-step (8851) and adom-pdf-viewer (8981) are reachable from pup /// through the same wiki-proxy host pattern. fn proxy_url_for_port(port: u16) -> String {@@ -729,7 +1007,7 @@ fn proxy_url_for_port(port: u16) -> String { return format!("https://{slug}.adom.cloud/proxy/{port}/"); } // Hydrogen / code-server exposes the public proxy template here, e.g.- // https://<slug>.adom.cloud/proxy/{{port}}/ — substitute the real port.+ // https://<slug>.adom.cloud/proxy/{{port}}/, substitute the real port. if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") { if tpl.contains("{{port}}") { return tpl.replace("{{port}}", &port.to_string());@@ -829,7 +1107,7 @@ fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> { } /// Brutal self-audit: for every chip (the active project if set), report ✓/✗ for-/// EVERY expected artifact — datasheet, 3D STEP, KiCad symbol+footprint, the card+/// EVERY expected artifact, datasheet, 3D STEP, KiCad symbol+footprint, the card /// thumbnails (symbol.svg / footprint.svg / 3d-iso.png), the Fusion/EAGLE .lbr, /// and info.json. A chip is "complete" ONLY if every REQUIRED box is green. This /// is the tab that stops anyone (human or AI) from calling it done when it isn't.@@ -864,7 +1142,7 @@ fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> { if !pm.iter().any(|m| m == &mpn) { continue; } } let d = e.path();- // REQUIRED — files a complete card MUST have+ // REQUIRED, files a complete card MUST have let pdf = valid_pdf(d.join(format!("{mpn}.pdf"))); let step = valid_step(d.join(format!("{mpn}.step"))); let sym = nonempty(d.join(format!("{mpn}.kicad_sym")), 50);@@ -872,7 +1150,7 @@ fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> { let sym_svg = nonempty(d.join(format!("{mpn}-symbol.svg")), 500); let fp_svg = nonempty(d.join(format!("{mpn}-footprint.svg")), 500); let thumb3d = nonempty(d.join(format!("{mpn}-3d-iso.png")), 2000);- // EXTRA — nice-to-have+ // EXTRA, nice-to-have let lbr = nonempty(d.join(format!("{mpn}.lbr")), 50); let info = d.join("info.json").exists(); // Description: the human "what is this chip" line shown on the card +@@ -936,8 +1214,7 @@ fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) - use serde_json::json; let has = |f: String| d.join(f).is_file(); - // The DISCOVERY source (where the CAD was found) labels the format lines —- // CSE / SnapEDA / UltraLibrarian — NOT the content_origin ("manufacturer"),+ // The DISCOVERY source (where the CAD was found) labels the format lines, // CSE / SnapEDA / UltraLibrarian, NOT the content_origin ("manufacturer"), // because CSE is an aggregator, not the maker. Read it from file-provenance. let prov = std::fs::read_to_string(d.join(format!("{mpn}-file-provenance.json"))).ok() .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());@@ -951,13 +1228,13 @@ fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) - .unwrap_or_default(); // PROVENANCE LABELING RULE (John, 2026-06-23): label by WHERE it was actually // downloaded, never the content_origin. ONLY the maker's OWN site counts as- // "the manufacturer" — and then NAME the site (ti.com / analog.com / st.com).+ // "the manufacturer", and then NAME the site (ti.com / analog.com / st.com). // The moment they hand off to UltraLibrarian / SnapMagic / Component Search- // Engine, that aggregator is the source — NOT the manufacturer.+ // Engine, that aggregator is the source, NOT the manufacturer. let src_label = |disc: &str| crate::provenance::source_label(disc, &manufacturer); let src = src_label(disc); - // Where the GLOBAL chip STEP came from — for the asterisk tooltip on lines+ // Where the GLOBAL chip STEP came from, for the asterisk tooltip on lines // whose own library had no 3D ("we swapped in the STEP we got from <X>"). let step_e = prov.as_ref().and_then(|p| p.get(format!("{mpn}.step"))); let step_disc = step_e.and_then(|e| e.get("discovery_source")).and_then(|s| s.as_str()).unwrap_or(disc);@@ -969,7 +1246,7 @@ fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) - // API_BASE. `original=true` = the raw archive/file we pulled off the internet // (for the user to inspect); `false` = a specific file we extracted from it. let dl = |name: &str, file: String, original: bool| json!({ "name": name, "file": file, "original": original });- // The original CSE bundle zip (the exact bytes downloaded) — shared by every+ // The original CSE bundle zip (the exact bytes downloaded), shared by every // EDA-format line, since CSE ships one archive with all formats inside. let cse_zip = has(format!("{mpn}.cse-bundle.zip")); @@ -1000,7 +1277,7 @@ fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) - "step_source": step_source, "step_url": step_url, "files": files, "note": note })); } - // 2) One line PER EDA FORMAT in the downloaded bundle — each library has its+ // 2) One line PER EDA FORMAT in the downloaded bundle, each library has its // own symbol + footprint, and (for CSE/UL/SnapEDA bundles) its own STEP. // Each line carries download links to the EXACT files we used. let three_lib = json!(has_global_step); // CSE/UL/SnapEDA bundles ship the STEP with each format@@ -1038,7 +1315,7 @@ fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) - json!(lines) } -/// The CANONICAL sourcing-priority — the single source of truth for "what order+/// The CANONICAL sourcing-priority, the single source of truth for "what order /// chip-fetcher searches the entire internet for electronics data." The /// dashboard's Sourcing tab renders this verbatim, and the AI must follow it. /// Three concerns, each with its own ordered ladder. ALWAYS manufacturer-first@@ -1050,13 +1327,13 @@ fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> { "concerns": [ { "concern": "1 · Identity & metadata",- "what": "What the part IS — description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",- "rule": "ALWAYS query the Adom data tools first — their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.",+ "what": "What the part IS, description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",+ "rule": "ALWAYS query the Adom data tools first, their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.", "steps": [- {"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella — searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"},+ {"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella, searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"}, {"tier": "Adom tool", "name": "adom-mouser", "detail": "Single-vendor deep lookup + richest stock/lifecycle. Backend keeps the key server-side (`adom-mouser serve`).", "cmd": "adom-mouser part <MPN>"},- {"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog — broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},- {"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog — assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"}+ {"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog, broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},+ {"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog, assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"} ] }, {@@ -1071,15 +1348,15 @@ fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> { { "concern": "3 · CAD bundle (STEP + symbol + footprint)", "what": "The 3D STEP model + KiCad/Altium/Fusion symbol & footprint.",- "rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER — never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.",+ "rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER, never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.", "steps": [- {"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab — the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"},+ {"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab, the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"}, {"tier": "Aggregator skill", "name": "SnapMagic / SnapEDA", "detail": "Free CAD bundles, login-gated. Right after the manufacturer.", "skill": "playbooks/snapmagic.md"}, {"tier": "Aggregator skill", "name": "Mouser CSE (Component Search Engine / SamacSys)", "detail": "Mouser's ECAD Model / Library Loader. Same-origin XHR + Basic-auth; needs the ORDERABLE MPN (from step 1).", "cmd": "chip-fetcher cse <MPN> --mfr <name>", "skill": "playbooks/mouser-cse.md"},- {"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page — mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"},+ {"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page, mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"}, {"tier": "Aggregator skill", "name": "Arrow", "detail": "Niche distributor CAD.", "skill": "playbooks/sourcing-ladder.md"}, {"tier": "Aggregator skill", "name": "UltraLibrarian / Component Search Engine", "detail": "Only when the manufacturer links there explicitly. Captcha or UL-Pro login flows.", "skill": "playbooks/ultra-librarian.md"},- {"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com — LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"}+ {"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com, LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"} ] } ]@@ -1221,7 +1498,7 @@ fn spawn_subprocess_action( mpn: &str, ) -> Response<std::io::Cursor<Vec<u8>>> { use std::process::{Command, Stdio};- // Self-invoke by absolute path — name-independent so the rename to+ // Self-invoke by absolute path, name-independent so the rename to // adom-chip-fetcher (or any future rename) can't break it. let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into()); let mut cmd = Command::new(exe);
src/settings.rsadded+149@@ -0,0 +1,149 @@+//! chip-fetcher settings: one JSON file, typed by the default's type, enum+//! values validated, read back literal for literal (settings-layout skill:+//! "never trust the 200"). Lives in `~/.config/adom-chip-fetcher/settings.json`.++use anyhow::{anyhow, Result};+use serde_json::{json, Map, Value};+use std::path::PathBuf;++pub fn config_dir() -> PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());+ PathBuf::from(home).join(".config/adom-chip-fetcher")+}++pub fn path() -> PathBuf {+ config_dir().join("settings.json")+}++/// (key, default, allowed enum values or empty, human label)+pub const SCHEMA: &[(&str, &str, &[&str], &str)] = &[+ ("taskbar_progress", "indeterminate", &["indeterminate", "off"], "Taskbar progress during a lookup"),+ ("auto_zoom", "false", &[], "Auto-zoom the active card"),+ ("auto_select_active", "true", &[], "Follow the active lookup"),+ ("show_stock_badges", "true", &[], "Stock and lifecycle badges on cards"),+ ("activity_stale_seconds", "90", &[], "Heartbeat considered stale after (seconds)"),+];++fn parse_default(s: &str) -> Value {+ match s {+ "true" => Value::Bool(true),+ "false" => Value::Bool(false),+ _ => s.parse::<i64>().map(Value::from).unwrap_or_else(|_| Value::String(s.to_string())),+ }+}++pub fn defaults() -> Map<String, Value> {+ SCHEMA.iter().map(|(k, d, _, _)| (k.to_string(), parse_default(d))).collect()+}++/// Coerce + validate one value against its default's type.+fn coerce(key: &str, v: &Value) -> Result<Value> {+ let (_, d, allowed, _) = SCHEMA+ .iter()+ .find(|(k, _, _, _)| *k == key)+ .ok_or_else(|| anyhow!("unknown setting '{key}'"))?;+ let def = parse_default(d);+ let out = match def {+ Value::Bool(_) => match v {+ Value::Bool(b) => Value::Bool(*b),+ Value::String(s) => match s.as_str() {+ "true" | "on" | "1" | "yes" => Value::Bool(true),+ "false" | "off" | "0" | "no" => Value::Bool(false),+ _ => return Err(anyhow!("'{key}' wants true or false, got '{s}'")),+ },+ Value::Number(n) => Value::Bool(n.as_i64().unwrap_or(0) != 0),+ _ => return Err(anyhow!("'{key}' wants true or false")),+ },+ Value::Number(_) => match v {+ Value::Number(n) => Value::from(n.as_i64().ok_or_else(|| anyhow!("'{key}' wants an integer"))?),+ Value::String(s) => Value::from(s.trim().parse::<i64>().map_err(|_| anyhow!("'{key}' wants an integer, got '{s}'"))?),+ _ => return Err(anyhow!("'{key}' wants an integer")),+ },+ _ => {+ let s = match v {+ Value::String(s) => s.clone(),+ other => other.to_string().trim_matches('"').to_string(),+ };+ if !allowed.is_empty() && !allowed.contains(&s.as_str()) {+ return Err(anyhow!("'{key}' must be one of {}", allowed.join(", ")));+ }+ Value::String(s)+ }+ };+ Ok(out)+}++pub fn load() -> Map<String, Value> {+ let mut m = defaults();+ if let Ok(text) = std::fs::read_to_string(path()) {+ if let Ok(Value::Object(stored)) = serde_json::from_str::<Value>(&text) {+ for (k, v) in stored {+ if let Ok(c) = coerce(&k, &v) {+ m.insert(k, c);+ }+ }+ }+ }+ m+}++pub fn get(key: &str) -> Value {+ load().get(key).cloned().unwrap_or(Value::Null)+}++pub fn get_str(key: &str) -> String {+ match get(key) {+ Value::String(s) => s,+ Value::Bool(b) => b.to_string(),+ Value::Number(n) => n.to_string(),+ _ => String::new(),+ }+}++pub fn get_bool(key: &str) -> bool {+ get(key).as_bool().unwrap_or(false)+}++fn save(m: &Map<String, Value>) -> Result<()> {+ std::fs::create_dir_all(config_dir())?;+ let text = serde_json::to_string_pretty(&Value::Object(m.clone()))?;+ std::fs::write(path(), text)?;+ Ok(())+}++/// Merge a JSON object of changes; every value is validated before anything+/// is written. Returns the full settings after the write.+pub fn merge(changes: &Value) -> Result<Map<String, Value>> {+ let obj = changes.as_object().ok_or_else(|| anyhow!("settings body must be a JSON object"))?;+ let mut m = load();+ for (k, v) in obj {+ let c = coerce(k, v)?;+ m.insert(k.clone(), c);+ }+ save(&m)?;+ Ok(load())+}++pub fn reset() -> Result<Map<String, Value>> {+ let d = defaults();+ save(&d)?;+ Ok(load())+}++pub fn describe_json() -> String {+ let labels: Map<String, Value> = SCHEMA.iter().map(|(k, _, _, l)| (k.to_string(), json!(l))).collect();+ let enums: Map<String, Value> = SCHEMA+ .iter()+ .filter(|(_, _, a, _)| !a.is_empty())+ .map(|(k, _, a, _)| (k.to_string(), json!(a)))+ .collect();+ json!({+ "ok": true,+ "settings": load(),+ "defaults": defaults(),+ "labels": labels,+ "enums": enums,+ "path": path().display().to_string(),+ })+ .to_string()+}
src/sources/jst.rs+13−13@@ -1,18 +1,18 @@-//! JST (J.S.T. Mfg. Co., Ltd.) — connector CAD source plugin.+//! JST (J.S.T. Mfg. Co., Ltd.), connector CAD source plugin. //! //! JST is unusual: its CAD is **email-gated**. Hitting a download link does-//! NOT return the file — it returns a License Agreement form, and on submit+//! NOT return the file, it returns a License Agreement form, and on submit //! (since April 2025) JST EMAILS the CAD as a `.zip` attachment. The full //! flow (native-browser bridge, form fill, Gmail retrieval) lives in //! `playbooks/jst.md`. This module is just the URL/part-number plumbing. //! //! Two sites:-//! * jst.com (USA) — Cloudflare-walled against pup; spec tables only; it+//! * jst.com (USA), Cloudflare-walled against pup; spec tables only; it //! routes to jst-mfg.com for CAD. Drive it with the native-browser //! bridge, never pup.-//! * jst-mfg.com (global) — where the CAD + catalogs actually live.+//! * jst-mfg.com (global), where the CAD + catalogs actually live. //!-//! No account login is required for CAD — only the agreement form + a+//! No account login is required for CAD, only the agreement form + a //! reachable email address. /// The master index of every JST series ("Series Alphabet Search"). Each@@ -27,7 +27,7 @@ pub fn series_url(series_id: u32) -> String { format!("https://www.jst-mfg.com/product/index.php?series={series_id}") } -/// Global-site keyword search — use to resolve an MPN to its series when the+/// Global-site keyword search, use to resolve an MPN to its series when the /// series id is unknown. pub fn search_url(mpn: &str) -> String { let mut q = String::with_capacity(mpn.len());@@ -41,15 +41,15 @@ pub fn search_url(mpn: &str) -> String { } /// JST CAD document kinds. The `doc=` query param selects the format, BUT the-/// numeric mapping is per-series — newer series expose STEP, older ones only+/// numeric mapping is per-series, newer series expose STEP, older ones only /// IGES. ALWAYS read the column header on the series page; treat these as the /// common-case defaults (verified on the PH series, series=199), not gospel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DocKind {- Iges, // doc=1 (common) — 3D, .IGS- Step, // doc=2 (common) — 3D, .STEP ← preferred for chip-fetcher- Pdf3d, // doc=3 (common) — 3D-PDF- Pdf2d, // doc=4 (common) — 2D-PDF drawing+ Iges, // doc=1 (common), 3D, .IGS+ Step, // doc=2 (common), 3D, .STEP ← preferred for chip-fetcher+ Pdf3d, // doc=3 (common), 3D-PDF+ Pdf2d, // doc=4 (common), 2D-PDF drawing } impl DocKind {@@ -69,7 +69,7 @@ impl DocKind { /// 1. sets the PHP session binding for (series, doc, filename), and /// 2. returns the License Agreement HTML form. /// You then accept cookies, fill the form, and call the page's `download()`-/// JS — JST emails the `.zip`. `filename` is the part's zip name, e.g.+/// JS, JST emails the `.zip`. `filename` is the part's zip name, e.g. /// "PHR-2.zip". pub fn download_url(series_id: u32, doc: u32, filename: &str) -> String { format!(@@ -77,7 +77,7 @@ pub fn download_url(series_id: u32, doc: u32, filename: &str) -> String { ) } -/// Series catalog/datasheet PDF — a DIRECT download (no gate). `series_slug`+/// Series catalog/datasheet PDF, a DIRECT download (no gate). `series_slug` /// is the short uppercase series tag, e.g. "PH" → ePH.pdf. JST has no /// per-part datasheet; use the catalog + the 2D-PDF drawing. pub fn catalog_pdf_url(series_slug: &str) -> String {
src/sources/wiki.rs+11−11@@ -1,13 +1,13 @@ //! Adom Wiki as the FIRST part-source on the sourcing ladder. //! //! The wiki (https://wiki.adom.inc) hosts published `component` pages whose git-//! repo carries a complete, Adom-vetted CAD bundle — `<mpn>.kicad_sym`,+//! repo carries a complete, Adom-vetted CAD bundle, `<mpn>.kicad_sym`, //! `<mpn>.kicad_mod`, STEP/GLB, datasheet PDF, and the 3D-outline SVGs. If a //! part already exists there we REUSE that bundle instead of re-fetching and //! rebuilding from external vendors: faster, free, and guaranteed //! Adom-consistent geometry + provenance. //!-//! Reads only — the public read API needs no token. There is no HTTP crate in+//! Reads only, the public read API needs no token. There is no HTTP crate in //! this tree, so we shell `curl` (the same dependency-free style the rest of the //! CLI uses to drive sibling binaries). @@ -23,7 +23,7 @@ const WIKI: &str = "https://wiki.adom.inc"; pub struct WikiHit { pub slug: String, pub title: String,- pub url: String, // human page URL — recorded as provenance+ pub url: String, // human page URL, recorded as provenance } /// Normalize an MPN for matching: lowercase, drop all non-alphanumerics. So@@ -83,7 +83,7 @@ fn slug_candidates(mpn: &str) -> Vec<String> { v } -/// The MPN with hyphens stripped — the wiki's full-text search throws on+/// The MPN with hyphens stripped, the wiki's full-text search throws on /// hyphens (known bug), so we never send a raw hyphenated query. fn search_safe(mpn: &str) -> String { mpn.chars().filter(|c| *c != '-').collect()@@ -118,7 +118,7 @@ fn component_meta(slug: &str) -> Option<(String, String, String)> { Some((title, page_url(slug, &pj), page_mpn)) } -/// Human page URL — `wiki.adom.inc/<org>/<slug>` when we know the org, else+/// Human page URL, `wiki.adom.inc/<org>/<slug>` when we know the org, else /// `wiki.adom.inc/<slug>`. fn page_url(slug: &str, meta: &Value) -> String { let org = meta@@ -161,7 +161,7 @@ fn candidate_slugs(mpn: &str) -> Vec<String> { } } - // 3. full-text search (hyphens stripped — FTS throws on them)+ // 3. full-text search (hyphens stripped, FTS throws on them) if let Some(v) = curl_json(&format!("{WIKI}/api/v1/search?q={}", url_encode(&search_safe(mpn)))) { if let Some(arr) = v.get("results").and_then(|r| r.as_array()) { for r in arr {@@ -208,11 +208,11 @@ enum Pull { Aux(String), } -/// Classify a wiki file for pulling. `mpn` is the requested MPN — used both to+/// Classify a wiki file for pulling. `mpn` is the requested MPN, used both to /// match the canonical file prefix and to name targets. (We key off the /// requested MPN, not the page's component.mpn, because the on-disk wiki files /// are named after the real part number and some pages glue a description onto-/// component.mpn.) Only the CANONICAL file of each type is pulled — variant /+/// component.mpn.) Only the CANONICAL file of each type is pulled, variant / /// compare GLBs like `B2B-ZR-v2.glb` or `B2B-ZR-compare.glb` are skipped. fn classify(mpn: &str, path: &str) -> Option<Pull> { let base = path.rsplit('/').next().unwrap_or(path);@@ -252,7 +252,7 @@ fn has_core(mpn: &str, path: &str) -> bool { /// Find the component page that actually carries a CAD bundle AND whose MPN /// matches the request (normalized). Without the MPN check, a loose /// discover/search hit (or the wiki returning a popular page) would wrongly-/// "match" any query — verified the hard way: a nonsense MPN matched B2B-ZR.+/// "match" any query, verified the hard way: a nonsense MPN matched B2B-ZR. pub fn find_component(mpn: &str) -> Option<WikiHit> { let want = norm(mpn); for slug in candidate_slugs(mpn) {@@ -260,8 +260,8 @@ pub fn find_component(mpn: &str) -> Option<WikiHit> { Some(m) => m, None => continue, // not a component page (or 404) };- // Match on the page's component.mpn OR its slug — some pages glue a- // description onto component.mpn (e.g. "BQ7692003PWR — BQ76920 …"), so the+ // Match on the page's component.mpn OR its slug, some pages glue a+ // description onto component.mpn (e.g. "BQ7692003PWR, BQ76920 …"), so the // clean slug is the reliable identity. Either exact (normalized) match is // accepted; a loose discover/search hit for a different part is rejected. if norm(&page_mpn) != want && norm(&slug) != want {
src/sourcing.rs+10−10@@ -6,9 +6,9 @@ // first-heartbeat HINT and the `next-source` subcommand. // * MPN → manufacturer + canonical product-search URL, used by // `chip-fetcher mfr-probe`.-// * fetched_via_chain helpers — reading, appending, querying which+// * fetched_via_chain helpers, reading, appending, querying which // ladder step has been tried for a given MPN.-// * stage-label heuristics — parse a heartbeat stage string for+// * stage-label heuristics, parse a heartbeat stage string for // source names so the chain auto-updates without the AI writing it. // // All of this lives in code (not skill prose) because the agent doesn't@@ -22,7 +22,7 @@ use std::path::PathBuf; /// One step in the sourcing ladder. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Source {- /// Adom Wiki — reuse an already-published, Adom-vetted CAD bundle. FIRST on+ /// Adom Wiki, reuse an already-published, Adom-vetted CAD bundle. FIRST on /// the ladder: if the part exists on wiki.adom.inc we don't re-fetch from /// external vendors at all. AdomWiki,@@ -85,14 +85,14 @@ pub struct MfrGuess { /// Look up the manufacturer + canonical product-search URL for an MPN. /// Returns `None` only when we have no signal at all (very rare for /// real-world EE parts; passive R/C MPNs are the usual gap). Always pairs-/// the manufacturer name with a directly-openable URL — the AI's job is+/// the manufacturer name with a directly-openable URL, the AI's job is /// just to navigate there, not to figure out where there is. pub fn guess_mfr(mpn: &str) -> Option<MfrGuess> { let m = mpn.to_ascii_uppercase(); // Each entry: (prefix, manufacturer, search-URL template with {mpn} // placeholder; if the URL contains no placeholder it's a direct search).- // Order matters — longer prefixes first so e.g. "STM32" matches before+ // Order matters, longer prefixes first so e.g. "STM32" matches before // "ST" does. let table: &[(&str, &str, &str)] = &[ // MCUs / SoCs@@ -141,11 +141,11 @@ pub fn guess_mfr(mpn: &str) -> Option<MfrGuess> { ("BMA", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"), ("BMG", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"), - // Connectors — JST. CAD is EMAIL-GATED on jst-mfg.com (see+ // Connectors, JST. CAD is EMAIL-GATED on jst-mfg.com (see // playbooks/jst.md). Route every JST part to the global keyword // search, which resolves the part to its series page (where the // STEP/IGES download buttons + agreement form live). Do NOT hard-wire- // a series id — JST has 600+ series and a prefix can't pick one.+ // a series id, JST has 600+ series and a prefix can't pick one. // jst.com (USA) is Cloudflare-walled vs pup; use the native browser. // Housing prefixes (…R-/…P-) + board/SMT header prefixes (B*B-/S*B-/ // BM*B-/SM*B-) all funnel through search.@@ -212,7 +212,7 @@ fn normalize_slug(s: &str) -> String { } fn urlencoding(s: &str) -> String {- // Tiny URL-encoder — enough for MPN strings. Avoids a dep.+ // Tiny URL-encoder, enough for MPN strings. Avoids a dep. let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b {@@ -277,7 +277,7 @@ pub fn last_chain_source(mpn: &str) -> Option<String> { } /// Append an entry to info.json.fetched_via_chain, creating the file if-/// missing. Idempotent on (source, outcome) — won't double-log the same+/// missing. Idempotent on (source, outcome), won't double-log the same /// step result. Returns true if a new entry was added. pub fn append_chain(mpn: &str, source: Source, outcome: &str, note: Option<&str>) -> Result<bool> { let path = info_path(mpn);@@ -386,7 +386,7 @@ pub fn detect_source_attempt(stage: &str) -> Option<(Source, String)> { } else if s.contains("blocked") || s.contains("captcha") || s.contains("cdn") { "blocked" } else if s.contains("trying") || s.contains("probing") || s.contains("driving") || s.contains("falling to") {- // Just a navigation event, not yet an outcome — don't pollute the chain.+ // Just a navigation event, not yet an outcome, don't pollute the chain. return None; } else { "attempted"
src/taskbar.rsadded+172@@ -0,0 +1,172 @@+//! Windows taskbar progress on the dashboard's pup window.+//!+//! John (issue adom/adom-chip-fetcher#3): a part lookup is a long, open-ended+//! process, so it gets an INDETERMINATE taskbar bar for as long as it runs,+//! never a false estimate. Short verbs (cse, ds2sf, thumbnails) get a+//! determinate bar that fills over a persisted running average, the way pup+//! does it (`~/.adom/pup-verb-timing.json` shape: `{"<op>":{"n":N,"avgMs":M}}`).+//!+//! Everything here is best effort and gated by the `taskbar_progress`+//! setting: a paint must never fail a fetch. Each paint is one+//! `adom-bridge pup_progress` process, so we paint on change only.++use serde_json::{json, Value};+use std::path::PathBuf;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use std::time::{Duration, Instant};++pub const DASHBOARD_SESSION: &str = "adom-chip-fetcher-dashboard";++fn enabled() -> bool {+ crate::settings::get_str("taskbar_progress") != "off"+}++fn push(mode: &str, value: Option<u8>, note: &str) {+ if !enabled() || !crate::bridge::available() {+ return;+ }+ let mut body = json!({"sessionId": DASHBOARD_SESSION, "mode": mode});+ if let Some(v) = value {+ body["value"] = json!(v.min(100));+ }+ if !note.is_empty() {+ body["note"] = json!(note);+ }+ // Fire and forget, bounded: a stuck bridge must not stall the caller.+ let _ = std::process::Command::new("timeout")+ .arg("8")+ .arg(bridge_bin())+ .args(["--ai-thread", &crate::bridge::ai_thread(), "pup_progress", &body.to_string()])+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .status();+}++fn bridge_bin() -> String {+ let mut c = crate::bridge::command();+ c.get_program().to_string_lossy().to_string()+}++/// Indeterminate bar for the whole lookup.+pub fn start_indeterminate(note: &str) {+ push("indeterminate", None, note);+}++pub fn progress(pct: u8, note: &str) {+ push("value", Some(pct), note);+}++pub fn clear() {+ push("off", None, "");+}++// ------------------------------------------------------------- durations++fn durations_path() -> PathBuf {+ crate::settings::config_dir().join("durations.json")+}++fn load_durations() -> Value {+ std::fs::read_to_string(durations_path())+ .ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| json!({}))+}++const DEFAULT_MS: &[(&str, u64)] = &[("cse", 45_000), ("ds2sf", 60_000), ("thumbnails", 20_000), ("validate", 30_000), ("refetch", 60_000)];++pub fn estimate_ms(op: &str) -> u64 {+ load_durations()+ .get(op)+ .and_then(|t| t.get("avgMs"))+ .and_then(|v| v.as_u64())+ .or_else(|| DEFAULT_MS.iter().find(|(k, _)| *k == op).map(|(_, v)| *v))+ .unwrap_or(30_000)+}++/// pup's rule: first sample sets the average, then a 0.75/0.25 EMA; absurd+/// samples (a clock jump, a forgotten terminal) are not samples.+pub fn record(op: &str, ms: u64) {+ if ms > 30 * 60 * 1000 {+ return;+ }+ let mut d = load_durations();+ let (n, avg) = d+ .get(op)+ .map(|t| (t["n"].as_u64().unwrap_or(0), t["avgMs"].as_u64().unwrap_or(ms)))+ .unwrap_or((0, ms));+ let avg = if n == 0 { ms } else { (avg * 3 + ms) / 4 };+ d[op] = json!({"n": (n + 1).min(100_000), "avgMs": avg});+ let _ = std::fs::create_dir_all(crate::settings::config_dir());+ let _ = std::fs::write(durations_path(), d.to_string());+}++pub fn durations_json() -> String {+ json!({"ok": true, "path": durations_path().display().to_string(), "durations": load_durations()}).to_string()+}++// ------------------------------------------------------- determinate run++/// A determinate bar that fills to 92% over the running-average estimate and+/// completes on `finish()`. Dropped early (an `?` error path) it clears.+pub struct ProgressRun {+ stop: Arc<AtomicBool>,+ handle: Option<std::thread::JoinHandle<()>>,+ op: String,+ started: Instant,+ finished: bool,+}++impl ProgressRun {+ pub fn start(op: &str, note: &str) -> Self {+ let est = estimate_ms(op).max(200) as f64;+ let stop = Arc::new(AtomicBool::new(false));+ let (s2, n2) = (stop.clone(), note.to_string());+ let started = Instant::now();+ let active = enabled();+ let handle = std::thread::spawn(move || {+ if !active {+ return;+ }+ let mut last = 0u8;+ while !s2.load(Ordering::Relaxed) {+ let pct = ((started.elapsed().as_millis() as f64 / est).min(0.92) * 100.0) as u8;+ let pct = pct.max(2);+ if pct != last {+ progress(pct, &n2);+ last = pct;+ }+ std::thread::sleep(Duration::from_millis(500));+ }+ });+ ProgressRun { stop, handle: Some(handle), op: op.into(), started, finished: false }+ }++ pub fn finish(mut self) {+ self.stop.store(true, Ordering::Relaxed);+ if let Some(h) = self.handle.take() {+ let _ = h.join();+ }+ record(&self.op, self.started.elapsed().as_millis() as u64);+ if enabled() {+ progress(100, "done");+ std::thread::sleep(Duration::from_millis(220));+ clear();+ }+ self.finished = true;+ }+}++impl Drop for ProgressRun {+ fn drop(&mut self) {+ if self.finished {+ return;+ }+ self.stop.store(true, Ordering::Relaxed);+ if let Some(h) = self.handle.take() {+ let _ = h.join();+ }+ clear();+ }+}
src/thumbnails.rs+11−12@@ -1,7 +1,7 @@ // Thumbnail generation by shelling to the canonical `chip-thumbnailer` CLI. // // chip-thumbnailer is the single source of truth for rendering the three-// per-chip artifacts under library/<MPN>/ — keeping the rendering logic+// per-chip artifacts under library/<MPN>/, keeping the rendering logic // (KiCad-CLI invocations, SVG dark theming, OCCT iso-pose render) in one // place across every Adom tool that needs them. //@@ -9,8 +9,8 @@ // <MPN>-symbol.svg // <MPN>-footprint.svg // <MPN>-3d-iso-sm.png (160×120)-// <MPN>-3d-iso.png (320×240) — primary, used by the dashboard-// <MPN>-3d-iso-lg.png (800×600) — used for full-screen pup tab on click+// <MPN>-3d-iso.png (320×240), primary, used by the dashboard+// <MPN>-3d-iso-lg.png (800×600), used for full-screen pup tab on click // // chip-thumbnailer is idempotent: it skips artifacts whose source hasn't // changed since the last render.@@ -46,7 +46,7 @@ pub fn generate_for(mpn: &str, force: bool) -> Result<GenReport> { .arg("once") .arg(mpn) .output()- .context("running chip-thumbnailer once — install with `chip-thumbnailer install`")?;+ .context("running chip-thumbnailer once, install with `chip-thumbnailer install`")?; let mut report = GenReport { mpn: mpn.to_string(),@@ -97,7 +97,7 @@ pub fn generate_for(mpn: &str, force: bool) -> Result<GenReport> { /// Render the ds2sf datasheet-extracted symbol/footprint to their own variant /// thumbnails (`<mpn>-symbol.ds2sf.svg`, `<mpn>-footprint.ds2sf.svg`) so the /// provenance carat shows a ds2sf option distinct from the vendor download.-/// Best-effort — skipped silently if the tools or the ds2sf extraction are absent.+/// Best-effort, skipped silently if the tools or the ds2sf extraction are absent. fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { // The canonical SYM/FP tile must show the ACTUAL vendor download (the source // the carat labels "SnapEDA"/etc.), not the ds2sf datasheet synthesis.@@ -130,16 +130,16 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { } // The full chip-icon family (transparent iso icon, vector OUTLINE styles via // OCCT HLR, and the name-lasered emboss + named variants) is produced by- // `chip-thumbnailer once` above — chip-thumbnailer is the chip-icon factory+ // `chip-thumbnailer once` above, chip-thumbnailer is the chip-icon factory // and calls service-step2glb's /outline + /name-emboss for the OCCT work. // chip-fetcher no longer shells the local occt-*.py scripts for these. - // Canonical SYMBOL render — done LAST, AFTER chip-thumbnailer produced the+ // Canonical SYMBOL render, done LAST, AFTER chip-thumbnailer produced the // 3D-chip OUTLINE assets (`<mpn>-3d-outline-<style>-named.svg`) in the `once` // call. John's rule: the DEFAULT symbol shows the 3D chip as a WHITE vector // OUTLINE with the chip name lasered on, composited into the body. We let // `adom-symbol render` auto-discover that white outline+name asset (its- // default layout) — we no longer pass `--iso`, because the shaded raster read+ // default layout), we no longer pass `--iso`, because the shaded raster read // as a gray box and the BLACK "bold" outline was invisible on the dark canvas. let canon_sym = dir.join(format!("{mpn}.kicad_sym")); if canon_sym.is_file() {@@ -152,7 +152,7 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { // Foreign-format libraries (EAGLE/Fusion .lbr) → convert to KiCad sym+mod via // adom-sfconvert so each format renders its OWN geometry in the carat instead // of falling back to the canonical KiCad render. Altium .SchLib/.PcbLib are- // OLE binary — adom-sfconvert errors honestly there, so no Altium thumb yet.+ // OLE binary, adom-sfconvert errors honestly there, so no Altium thumb yet. convert_foreign_libraries(dir, mpn); // Per-source vendor variants kept by import (<mpn>.<method>.kicad_sym/.kicad_mod)@@ -185,8 +185,7 @@ fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) { /// Idempotent + best-effort: if the converted files already exist, or the /// converter can't handle the format (Altium OLE binary), we leave it alone. fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) {- // EAGLE / Fusion 360 .lbr → NATIVE via adom-sfconvert (eagle.rs, pure Rust —- // no service round-trip). Emits export .kicad_sym/.kicad_mod AND the native+ // EAGLE / Fusion 360 .lbr → NATIVE via adom-sfconvert (eagle.rs, pure Rust, // no service round-trip). Emits export .kicad_sym/.kicad_mod AND the native // SVG thumbnails (<mpn>.fusion-symbol.svg / <mpn>.fusion-footprint.svg), // which we move to the canonical per-source names so the variant loop skips // the service render.@@ -207,7 +206,7 @@ fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) { ); } // Altium .SchLib (symbol) + .PcbLib (footprint) → NATIVE via adom-sfconvert- // (altium-schlib / altium-pcblib, pure Rust — no service round-trip). It+ // (altium-schlib / altium-pcblib, pure Rust, no service round-trip). It // emits the export .kicad_sym/.kicad_mod AND the native SVG thumbnail // (<mpn>.altium-symbol.svg / <mpn>.altium-footprint.svg). We move those SVGs // to chip-fetcher's canonical per-source thumbnail names so the variant
src/ui/index.html+1655−2277@@ -2,870 +2,932 @@ <html lang="en"> <head> <meta charset="utf-8">-<title>chip-fetcher viewer</title>-<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2300b8b0'><rect x='6' y='6' width='12' height='12' rx='1.2'/><rect x='9' y='2' width='2' height='2'/><rect x='13' y='2' width='2' height='2'/><rect x='9' y='20' width='2' height='2'/><rect x='13' y='20' width='2' height='2'/><rect x='2' y='9' width='2' height='2'/><rect x='2' y='13' width='2' height='2'/><rect x='20' y='9' width='2' height='2'/><rect x='20' y='13' width='2' height='2'/></svg>">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Chip Fetcher</title>+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><rect x='7' y='7' width='10' height='10' rx='1.5'/><path d='M9 7V4M12 7V4M15 7V4M9 20v-3M12 20v-3M15 20v-3M7 9H4M7 12H4M7 15H4M20 9h-3M20 12h-3M20 15h-3'/></svg>"> <style>- /* --- Adom canonical font loaders (per adom-app-header §Required @font-face loaders) --- */- @font-face {- font-family: 'Familjen Grotesk';- src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');- font-weight: 700; font-display: swap;- }- @font-face {- font-family: 'Familjen Grotesk';- src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');- font-weight: 600; font-display: swap;- }- @font-face {- font-family: 'Familjen Grotesk';- src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');- font-weight: 500; font-display: swap;- }- @font-face {- font-family: 'Satoshi';- src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');- font-weight: 400; font-display: swap;- }- @font-face {- font-family: 'Satoshi';- src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');- font-weight: 500; font-display: swap;- }- @font-face {- font-family: 'JetBrains Mono';- src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');- font-weight: 400; font-display: swap;- }- @font-face {- font-family: 'JetBrains Mono';- src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');- font-weight: 500; font-display: swap;- }- @font-face {- font-family: 'JetBrains Mono';- src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');- font-weight: 600; font-display: swap;- }-- :root {- /* Canonical Adom tokens */- --bg: #0d1117;- --surface: #161b22;- --elevated: #1c2128;- --border: #30363d;- --border-muted: #21262d;- --text: #e6edf3;- --text2: #7d8590;- --text3: #484f58;- --accent: #00b8b0; /* Adom teal — canonical brand primary accent */-- /* chip-fetcher semantic accents */- --green: #3fb950;- --amber: #f59e0b;- --red: #f85149;- --grey: #4b5563;-- /* legacy aliases (kept for existing rules below) */- --panel: var(--surface);- --muted: var(--text2);- }- * { box-sizing: border-box; }- html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }- body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; min-height: 100vh; }- /* --- Canonical Adom app-header (single row, 44 px) --- */- header {- display: flex;- align-items: center;- gap: 12px;- padding: 0 16px;- height: 44px;- border-bottom: 1px solid var(--border-muted);- background: var(--surface);- position: relative;- font-family: 'Familjen Grotesk', sans-serif;- flex-shrink: 0;- }- header .brand {- display: flex; align-items: center; gap: 8px;- color: var(--accent);- font-size: 13px; font-weight: 600;- letter-spacing: 0.01em;- cursor: default;- flex-shrink: 0;- outline: none;- }- header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }- header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }- header .brand .app-name { color: var(--accent); }- header .brand .app-subtitle { color: var(--text2); font-weight: 500; }- header .brand .sep { color: var(--text3); font-weight: 400; }- header .vsep {- width: 1px; height: 18px;- background: var(--border);- flex-shrink: 0;- }- header .subject {- display: flex; align-items: center; gap: 8px;- min-width: 0;- flex: 1;- cursor: default;- }- header .subject-name {- color: var(--text);- font-family: 'Familjen Grotesk', sans-serif;- font-size: 14px; font-weight: 600;- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;- }- header .subject-name.idle { color: var(--text3); font-weight: 500; }- header .state-pill {- display: none;- font-size: 10px; font-weight: 700; letter-spacing: 0.5px;- padding: 2px 8px;- border-radius: 10px;- font-family: 'Familjen Grotesk', sans-serif;- flex-shrink: 0;- }- header .state-pill.show { display: inline-flex; }- header .state-pill.full { background: rgba(63,185,80,0.18); color: var(--green); }- header .state-pill.stub { background: rgba(245,158,11,0.20); color: var(--amber); }- header .state-pill.fetching { background: rgba(0,184,176,0.20); color: var(--accent); animation: pillpulse 1.2s ease-in-out infinite; }- @keyframes pillpulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }- header .subject-meta {- display: flex; align-items: center; gap: 6px;- color: var(--text2);- font-family: 'JetBrains Mono', monospace;- font-size: 11px;- white-space: nowrap;- }- header .subject-meta .sep { color: var(--text3); }- @media (max-width: 720px) {- header .subject-meta { display: none; }- }- header .actions {- display: flex; align-items: center; gap: 6px;- margin-left: auto; flex-shrink: 0;- }-- /* --- Interactive popover (hover-tooltip you can move into) --- */- header .hover-popover {- position: absolute;- top: calc(100% + 6px);- left: 16px;- background: var(--elevated);- border: 1px solid var(--border);- border-radius: 8px;- box-shadow: 0 8px 24px rgba(0,0,0,0.5);- padding: 12px 14px;- min-width: 300px;- max-width: 420px;- font-family: 'Satoshi', sans-serif;- font-size: 12px;- color: var(--text);- z-index: 99999;- opacity: 0; transform: translateY(-2px);- pointer-events: none;- transition: opacity 120ms ease, transform 120ms ease;- }- header .hover-popover.show {- opacity: 1; transform: none;- pointer-events: auto;- }- header .hover-popover.compact { min-width: 220px; }- header .hover-popover .popover-title {- font-family: 'Familjen Grotesk', sans-serif;- font-size: 14px; font-weight: 700;- color: var(--text);- margin-bottom: 8px;- }- header .hover-popover .popover-row {- display: flex; gap: 12px; padding: 3px 0;- align-items: baseline;- }- header .hover-popover .popover-row .k {- color: var(--text3);- font-family: 'JetBrains Mono', monospace;- font-size: 10px; text-transform: lowercase;- width: 64px; flex-shrink: 0;- }- header .hover-popover .popover-row .v {- color: var(--text);- font-family: 'Satoshi', sans-serif;- font-size: 12px;- flex: 1;- }- header .hover-popover .popover-actions {- margin-top: 10px; padding-top: 10px;- border-top: 1px solid var(--border-muted);- display: flex; gap: 12px;- }- header .hover-popover .popover-link {- color: var(--accent);- text-decoration: none;- font-family: 'Familjen Grotesk', sans-serif;- font-size: 12px; font-weight: 600;- }- header .hover-popover .popover-link:hover { text-decoration: underline; }-- /* --- Toolbar (filters / search / credentials) — separate row below header --- */- .toolbar {- display: flex; align-items: center; gap: 8px;- padding: 8px 16px;- border-bottom: 1px solid var(--border-muted);- background: var(--bg);- flex-wrap: wrap;- flex-shrink: 0;- }- .toolbar input[type=search],- .toolbar select,- .toolbar button {- background: var(--surface);- border: 1px solid var(--border);- color: var(--text);- padding: 6px 10px;- border-radius: 6px;- font: 12px/1.4 'Satoshi', sans-serif;- }- .toolbar input[type=search] { min-width: 220px; }- .toolbar input[type=search]:focus,- .toolbar select:focus,- .toolbar button:hover { border-color: var(--accent); outline: none; }- .toolbar button { cursor: pointer; }- .toolbar .spacer { margin-left: auto; }- .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }- .modal-overlay.open { display: flex; }- .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 24px; min-width: 480px; max-width: 600px; }- .modal h3 { margin: 0 0 12px; font-size: 18px; }- .modal .helper { color: var(--muted); font-size: 12px; margin-bottom: 16px; line-height: 1.5; }- .modal label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }- .modal input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 4px; font: inherit; }- .modal .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }- .modal .btn-primary { background: var(--accent); border: 1px solid var(--accent); color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }- .modal .btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }- .creds-list { margin-bottom: 16px; }- .creds-list .row { display: flex; justify-content: space-between; align-items: center; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; font-size: 13px; }- .creds-list .row .info { color: var(--muted); font-size: 11px; }- .creds-list .row code { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text); }- .creds-list .row button { background: transparent; border: 1px solid var(--border); color: var(--red); padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; }- .msg { font-size: 13px; padding: 8px; border-radius: 4px; margin-top: 12px; }- .msg.ok { background: rgba(34,197,94,.15); color: var(--green); }- .msg.err { background: rgba(239,68,68,.15); color: var(--red); }- main { display: grid; grid-template-columns: 1fr 380px; flex: 1; min-height: 0; }- .grid { padding: 20px; overflow-y: auto; }- .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }- .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; cursor: pointer; transition: border-color .15s; }- .card:hover { border-color: var(--accent); }- .card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.25); }- .card .mpn-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; margin-bottom: 4px; }- .card .mpn { font-weight: 600; font-size: 15px; word-break: break-all; flex: 1; min-width: 0; }- .card .reveal-btn { flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 3px 5px; cursor: pointer; line-height: 0; display: inline-flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .12s, color .12s, border-color .12s; }- .card:hover .reveal-btn, .card.selected .reveal-btn { opacity: 1; }- .card .reveal-btn:hover { color: var(--accent); border-color: var(--accent); }- .card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }- /* Thumbnail strip — three conceptual slots: SYMBOL | FOOTPRINT | 3D GROUP.- The 3D group bundles Z↑ + Y↑ + the ▾ orientation picker because they all represent- "the STEP file shown from different angles" — distinct from SYM (solved symbol) and- FP (solved footprint). */- /* Two-tier thumbnail hierarchy: SYM + FP are the big primary artifacts you- design with; the 3D up-axis QA + .lbr packaging action are a smaller- secondary row beneath. */- .card .thumbs { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }- .card .thumb-row { display: grid; gap: 6px; }- .card .thumb-row.primary { grid-template-columns: 1fr 1fr; }- /* 3D models are the row that tells near-identical parts (JST connectors) apart,- so give the 3D group the full card width (big Z↑/Y↑), and shrink the .lbr- build action to a thin bar beneath it. */- .card .thumb-row.secondary { grid-template-columns: 1fr; align-items: stretch; }- .card .thumb-row.secondary .thumb-lbr { aspect-ratio: auto; height: 38px; flex-direction: row; }- .card .thumb-3d-group { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; position: relative; border: 1px solid var(--border-muted); border-radius: 6px; padding: 2px; background: rgba(64,75,85,.15); }- .card .thumb-3d-group .orient-more-btn { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--panel); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 0 6px; font-size: 9px; line-height: 1.5; cursor: pointer; opacity: 0; transition: opacity .12s; }- .card:hover .thumb-3d-group .orient-more-btn, .card.selected .thumb-3d-group .orient-more-btn { opacity: 1; }- .card .thumb-3d-group .orient-more-btn:hover { color: var(--accent); border-color: var(--accent); }- /* Always-visible 3D source chip (KiCad lib / Mfr direct / SnapMagic …). Color = source, not the model's CAD color. Namespaced src3d- to avoid the sym/fp carat's .src-chip. */- .card .thumb-3d-group .src3d-chip { position: absolute; top: 3px; left: 3px; z-index: 6; display: inline-flex; align-items: center; gap: 4px;- font-size: 9px; line-height: 1; font-weight: 600; letter-spacing: .2px; padding: 3px 6px; border-radius: 999px;- background: rgba(13,17,23,.85); color: #cdd9e5; border: 1px solid var(--border); pointer-events: auto; }- .card .thumb-3d-group .src3d-chip .src3d-dot { width: 7px; height: 7px; border-radius: 50%; background: #8b949e; flex: none; }- .card .thumb-3d-group .src3d-chip.src3d-mfr { color: #7ee2c8; border-color: #1f6f5c; } .src3d-chip.src3d-mfr .src3d-dot { background: #19c37d; }- .card .thumb-3d-group .src3d-chip.src3d-kicad { color: #9fc6ff; border-color: #2c4a73; } .src3d-chip.src3d-kicad .src3d-dot { background: #4f8cff; }- .card .thumb-3d-group .src3d-chip.src3d-ul { color: #ffd479; border-color: #6b5320; } .src3d-chip.src3d-ul .src3d-dot { background: #e6b450; }- .card .thumb-3d-group .src3d-chip.src3d-snap { color: #d6a8ff; border-color: #4a2c6b; } .src3d-chip.src3d-snap .src3d-dot { background: #b46bff; }- .card .thumb-3d-group .src3d-chip.src3d-mouser, .card .thumb-3d-group .src3d-chip.src3d-dk { color: #9fc6ff; } .src3d-chip.src3d-mouser .src3d-dot, .src3d-chip.src3d-dk .src3d-dot { background: #5a8fd6; }- .card .thumb-3d-group .src3d-chip.src3d-unknown { color: #ff9b8a; border-color: #6b2f24; } .src3d-chip.src3d-unknown .src3d-dot { background: #ff5a52; }- /* Card zoom — animate from the card's grid position to centered,- so the user tracks which card is zooming. Uses CSS transition on- top/left/transform with a short ease-out. */- .card-zoom-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0); cursor: pointer; transition: background .25s ease-out; }- .card-zoom-backdrop.visible { background: rgba(0,0,0,0.70); }- .card-zoom-backdrop .card-zoomed-wrapper { position: fixed; z-index: 201; pointer-events: auto; cursor: default; transition: top .3s ease-out, left .3s ease-out, transform .3s ease-out; }- .card-zoom-backdrop .card-zoomed-wrapper .card { transform-origin: top left; border: 2px solid var(--accent); box-shadow: 0 24px 80px rgba(0,0,0,0.7); }- .card-zoom-backdrop .card .card-actions { opacity: 1 !important; }- .card-zoom-backdrop .card .orient-more-btn { opacity: 1 !important; }- .card-zoom-backdrop .card .reveal-btn { opacity: 1 !important; }- /* Always-visible close button + full-screen scrollable mode (phones). */- .card-zoom-x { position: fixed; top: 12px; right: 12px; z-index: 210; width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--border); background: rgba(13,17,23,.92); color: #e6edf3; font-size: 19px; line-height: 1; cursor: pointer; display: grid; place-items: center; box-shadow: 0 6px 22px rgba(0,0,0,.6); }- .card-zoom-x:hover, .card-zoom-x:active { border-color: var(--accent); color: var(--accent); }- .card-zoom-backdrop.fullscreen { overflow-y: auto; -webkit-overflow-scrolling: touch; display: flex; align-items: flex-start; justify-content: center; padding: 64px 0 36px; }- .card-zoom-backdrop.fullscreen .card-zoomed-wrapper { position: relative !important; left: auto !important; top: auto !important; }- /* Fluid 2-column zoom (desktop): media (thumbnails) on the LEFT, title + meta +- badges + Open-in/Send-to on the RIGHT — so a zoomed card fills the horizontal- room and stays short enough to scale up large, instead of a tall narrow strip. */- .card-zoom-backdrop .card.zoom-fluid { display: grid; grid-template-columns: minmax(320px, 1.25fr) minmax(300px, 1fr); column-gap: 26px; align-items: start; padding: 22px; }- .card-zoom-backdrop .card.zoom-fluid .zoom-col-media,- .card-zoom-backdrop .card.zoom-fluid .zoom-col-info { min-width: 0; display: flex; flex-direction: column; gap: 10px; }- .card-zoom-backdrop .card.zoom-fluid .thumbs { margin: 0; }- .card-zoom-backdrop .card.zoom-fluid .zoom-actions { margin-top: 2px; }- /* In the ZOOM, show the FULL chip description (what the part actually is) —- not the 2-line clamp the grid card uses. John's repeated ask: the zoom must- explain the chip. Make it prominent and fully readable. */- .card-zoom-backdrop .card .card-desc {- -webkit-line-clamp: unset; display: block; overflow: visible;- font-size: 14px; line-height: 1.5; color: var(--text);- margin: 6px 0 12px; max-width: 60ch;- }- /* Tile background MATCHES the image's own baked-in canvas color, so any- whitespace introduced by aspect-ratio mismatch (KiCad SVG vs square- tile) blends seamlessly. Per-kind backgrounds via data-thumb-kind:- sym/fp → Adom dark canvas #0d1117 (chip-thumbnailer's SVG bg)- 3d → slate #404B55 (pyrender's iso-pose backdrop) */- .card .thumb { background: #0d1117; border: 1px solid var(--border-muted); border-radius: 6px; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; padding: 8%; overflow: hidden; position: relative; transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }- .card .thumb[data-thumb-kind="chip3d"], .card .thumb[data-thumb-kind="chip3d-yup"] { background: #404B55; padding: 4%; }- .card .thumb.thumb-lbr { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; color:#8aa0b8; background:linear-gradient(150deg,#1b2330,#222c3d); }- .card .thumb.thumb-lbr svg { width:34%; height:auto; }- .card .thumb.thumb-lbr:hover { color:#37c8a6; }- /* Active orientation gets an Adom-teal highlight ring + corner check.- The OTHER 3D thumb sits dimmer until hover, signalling "click to pick- this one if the active is wrong". */- .card .thumb[data-orient-active="true"] { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }- .card .thumb[data-orient-inactive="true"] { opacity: 0.55; }- .card .thumb[data-orient-inactive="true"]:hover { opacity: 1; }- .card .thumb .orient-check { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #0d1117; pointer-events: none; }- .card .thumb .orient-check svg { width: 11px; height: 11px; }- /* Lightweight "symbol re-rendering" overlay: a CSS-only spinner over the SYM- tile while the background outline+symbol regen runs after an up-axis switch.- pointer-events:none + a single dimmed tile = the rest of the dashboard stays- fully interactive while it spins. */- .card .thumb.refreshing img { opacity: .3; transition: opacity .2s; }- .card .thumb-spinner { position: absolute; top: 50%; left: 50%; width: 22px; height: 22px;- margin: -11px 0 0 -11px; border: 2px solid rgba(255,255,255,.22);- border-top-color: var(--accent); border-radius: 50%;- animation: thumbspin .7s linear infinite; pointer-events: none; z-index: 4; }- @keyframes thumbspin { to { transform: rotate(360deg); } }- .card .thumb:hover { border-color: var(--accent); transform: scale(1.06); z-index: 2; box-shadow: 0 6px 18px rgba(0,0,0,0.4), 0 0 0 1px var(--accent); }- /* Symbol/footprint tiles are non-interactive — no hover lift, no pointer. */- .card .thumb.thumb-static { cursor: default; }- .card .thumb.thumb-static:hover { transform: none; box-shadow: none; border-color: var(--border-muted); }- .card .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }- .card .thumb .thumb-label { position: absolute; bottom: 4px; right: 6px; font-size: 9px; color: rgba(230,237,243,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-shadow: 0 1px 2px rgba(0,0,0,0.6); pointer-events: none; }- .card .thumb.empty { background: var(--bg); border-style: dashed; padding: 8%; }- .card .thumb.empty .thumb-placeholder { color: var(--text3); font-size: 10px; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; }- /* Render FAILED (chip-thumbnailer errored) — distinct from "not generated"- so a failure isn't indistinguishable from an absent source file. */- .card .thumb.failed { background: rgba(220,80,60,.06); border-style: dashed; border-color: rgba(220,80,60,.45); padding: 6%; flex-direction: column; gap: 2px; }- .card .thumb.failed .thumb-failed { color: #ef7a6a; font-size: 10px; font-family: 'JetBrains Mono', monospace; font-weight: 600; letter-spacing: .05em; }- .card .thumb.failed .thumb-failed-sub { color: var(--text3); font-size: 8px; font-family: 'JetBrains Mono', monospace; }- /* Active-card emphasis — when this card is the one being heartbeated. */- .card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); animation: cardpulse 1.4s ease-in-out infinite; }- @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); } 50% { box-shadow: 0 0 0 2px rgba(0,184,176,.55), 0 0 32px rgba(0,184,176,.30); } }- .badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }- .b { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600; letter-spacing: .3px; }- .b.step { background: rgba(34,197,94,.15); color: var(--green); }- .b.kicad { background: rgba(34,197,94,.15); color: var(--green); }- .b.altium { background: rgba(34,197,94,.15); color: var(--green); }- .b.fusion { background: rgba(34,197,94,.15); color: var(--green); }- .b.partial-eda { background: rgba(245,158,11,.18); color: var(--amber); }- .b.pin1ok { background: rgba(0,184,177,.18); color: var(--accent); } /* pin-1 signed off in chipsmith */- .b.pin1 { background: rgba(245,158,11,.16); color: var(--amber); } /* pin-1 not yet aligned */- .b.pin1bad{ background: rgba(248,81,73,.18); color: #ff7b72; } /* chip pin-1 corner != footprint pin-1 corner */- .b.pdf { background: rgba(0,184,176,.15); color: var(--accent); }- .b.clickable { cursor: pointer; }- .b.clickable:hover { filter: brightness(1.25); }- .b.miss { background: rgba(75,85,99,.25); color: var(--grey); }- .b.full { background: rgba(34,197,94,.2); color: var(--green); }- .b.stub { background: rgba(245,158,11,.2); color: var(--amber); }- .b.src.mfr { background: rgba(34,197,94,.2); color: var(--green); }- .b.src.fb { background: rgba(245,158,11,.2); color: var(--amber); }- .b.src.unk { background: rgba(75,85,99,.25); color: var(--grey); }- .b.lifecycle-unk { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }- .b.stock-yes { background: rgba(34,197,94,.18); color: var(--green); }- .b.stock-low { background: rgba(245,158,11,.20); color: var(--amber); }- .b.stock-no { background: rgba(239,68,68,.18); color: var(--red); }- .b.stock-unk { background: rgba(75,85,99,.20); color: var(--grey); }- .b.price { background: rgba(0,184,176,.10); color: var(--accent); font-weight: 500; }- .b.nrnd { background: rgba(239,68,68,.20); color: var(--red); font-weight: 700; letter-spacing: .5px; }- .b.eol { background: rgba(220,38,38,.30); color: #fca5a5; font-weight: 700; letter-spacing: .5px; }- .b.new { background: rgba(34,197,94,.18); color: var(--green); font-weight: 600; letter-spacing: .3px; }- /* concur validation badges */- .b.val-golden { background: rgba(34,197,94,.25); color: var(--green); font-weight: 700; }- .b.val-golden-v { background: rgba(34,197,94,.15); color: var(--green); font-weight: 600; }- .b.val-divergent { background: rgba(245,158,11,.30); color: var(--amber); font-weight: 700; letter-spacing: .3px; }- .b.val-bad { background: rgba(239,68,68,.25); color: var(--red); font-weight: 700; }- .b.val-none { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }- /* per-card action buttons (re-validate, re-thumbnails, refetch) */- .card .card-actions { display: flex; gap: 4px; align-items: center; flex: 0 0 auto; opacity: 0; transition: opacity .12s; }- .card:hover .card-actions, .card.selected .card-actions { opacity: 1; }- /* Touch devices have no hover — keep the card actions (incl. the zoom button)- visible so a tap can reach them. */- @media (hover: none) { .card .card-actions { opacity: 1; } }- .card .card-btn { background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; line-height: 1.4; transition: color .12s, border-color .12s; }- .card .card-btn:hover { color: var(--accent); border-color: var(--accent); }- .card .card-btn:disabled { cursor: wait; }- #cf-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(16px); z-index: 100050; max-width: 90vw; background: rgba(17,22,31,.98); border: 1px solid var(--border2, #2b3645); border-radius: 11px; padding: 11px 16px; font-size: 13px; color: var(--text, #e6edf3); box-shadow: 0 14px 44px rgba(0,0,0,.6); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }- #cf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }- #cf-toast a { color: var(--accent, #00b8b1); font-weight: 700; margin-left: 6px; }- .openin-row { display: flex; gap: 6px; flex-wrap: wrap; }- /* Actions block injected into the zoom focus view. */- .zoom-actions { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }- .zoom-actions .zoom-sec { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin: 0 0 6px; }- .zoom-actions .openin-row, .zoom-actions .sendto-row { margin-bottom: 12px; }- .openin-row .openbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }- .openin-row .openbtn:hover { background: rgba(0,184,177,.16); border-color: var(--accent, #00b8b1); color: var(--accent, #00b8b1); }- .openin-row .openbtn:disabled { opacity: .55; cursor: wait; }- .sendto-row { display: flex; gap: 6px; flex-wrap: wrap; }- .sendto-row .sendbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }- .sendto-row .sendbtn:hover { background: var(--accent, #00b8b1); border-color: var(--accent, #00b8b1); color: #04201f; }- .sendto-row .sendbtn:disabled { opacity: .55; cursor: progress; }- #bridgeModal { position: fixed; inset: 0; z-index: 100001; display: none; align-items: center; justify-content: center; background: rgba(2,5,10,.62); backdrop-filter: blur(4px); padding: 20px; }- #bridgeModal.show { display: flex; }- #bridgeModal .bm-card { max-width: min(94vw,440px); background: rgba(17,22,31,.99); border: 1px solid var(--border); border-radius: 16px; padding: 22px 22px 18px; box-shadow: 0 30px 90px rgba(0,0,0,.7); text-align: center; }- #bridgeModal .bm-icon { font-size: 34px; margin-bottom: 6px; }- #bridgeModal .bm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }- #bridgeModal .bm-body { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px; }- #bridgeModal .bm-actions { display: flex; flex-direction: column; gap: 9px; }- #bridgeModal .bm-go { display: block; text-decoration: none; background: linear-gradient(180deg,#00b8b1,#009a94); color: #04201f; font-weight: 700; font-size: 13px; padding: 10px; border-radius: 10px; }- #bridgeModal .bm-hint { font-size: 11.5px; line-height: 1.5; color: var(--text, #cfd6e4); background: rgba(0,184,177,.08); border: 1px solid rgba(0,184,177,.22); border-radius: 9px; padding: 8px 10px; margin-bottom: 14px; text-align: left; }- #bridgeModal .bm-contribute { display: block; text-decoration: none; color: var(--muted); font-size: 12px; padding: 7px; border: 1px solid var(--border); border-radius: 10px; }- #bridgeModal .bm-contribute:hover { color: var(--accent); border-color: var(--accent); }- #bridgeModal .bm-dismiss { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: 12.5px; padding: 8px; border-radius: 10px; cursor: pointer; }- .card .refetch-select { font-size: 11px; padding: 1px 4px; width: 28px; background: var(--panel); color: var(--text); border: 1px solid var(--border); color-scheme: dark; overflow: hidden; text-overflow: ellipsis; }- .card .refetch-select option { background: var(--bg); color: var(--text); padding: 4px 8px; font-family: Satoshi, -apple-system, sans-serif; font-size: 12px; }- /* authoritative footprint SVG (ds2sf v0.5.1+) */- .card .auth-svg-thumb { position: relative; margin: 8px 0 4px; padding: 6px; background: #fff; border: 1px solid var(--border); border-radius: 6px; aspect-ratio: 2.4 / 1; display: flex; align-items: center; justify-content: center; overflow: hidden; }- .card .auth-svg-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }- .card .auth-svg-thumb.auth-canonical { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.3) inset; }- .card .auth-svg-thumb.auth-synth { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(245,158,11,.3) inset; }- .card .auth-svg-thumb .auth-svg-label { position: absolute; bottom: 2px; right: 4px; font-size: 9px; color: rgba(0,0,0,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; pointer-events: none; }- /* Auto-zoom toggle in toolbar */- .auto-zoom-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; margin-left: 8px; user-select: none; }- .auto-zoom-toggle input { accent-color: var(--accent); }- /* 6-orientation popover (button lives inside .thumb-3d-group) */- .card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }- /* symbol/footprint provenance carat — always-visible tappable source chip */- .card .thumb-src-group { position: relative; display: flex; }- .card .thumb-src-group .thumb { flex: 1; }- .card .src-chip { position: absolute; top: 4px; left: 4px; z-index: 4; display: inline-flex; align-items: center; gap: 4px;- font-size: 8.5px; font-weight: 700; letter-spacing: .02em; color: #d7efed; background: rgba(13,17,23,.82); backdrop-filter: blur(4px);- border: 1px solid rgba(0,184,177,.45); border-radius: 5px; padding: 2px 5px; cursor: pointer; max-width: 88%;- transition: border-color .12s, background .12s; -webkit-tap-highlight-color: transparent; }- .card .src-chip:hover, .card .src-chip:active { border-color: var(--accent); background: rgba(0,184,177,.16); }- .card .src-chip-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }- .card .src-chip-car { color: var(--muted); font-size: 8px; }- /* Variant picker is a side-by-side GALLERY with large thumbnails, so similar- variants (e.g. JST connector symbols/footprints) are actually tellable apart. */- .card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;- background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px;- display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; max-width: 520px;- box-shadow: 0 14px 36px rgba(0,0,0,.6); }- .card .src-opt { cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;- border: 1px solid var(--border-muted); border-radius: 8px; padding: 8px; width: 152px;- font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; }- .card .src-opt:hover, .card .src-opt:active { border-color: var(--accent); background: rgba(0,184,177,.08); }- .card .src-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }- .card .src-opt-thumb { width: 136px; height: 136px; object-fit: contain; background: #0d1117; border-radius: 6px; flex: none; }- .card .src-opt-lab { flex: none; text-align: center; line-height: 1.3; white-space: normal; }- .card .src-cur { color: var(--green); font-size: 9px; }- .card .src-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }- .card .src-dot.vendor { background: #00b8b1; }- .card .src-dot.ds { background: #d29922; }- .card .orient-opt { cursor: pointer; border: 1px solid var(--border-muted); border-radius: 6px; padding: 4px; background: #404B55; text-align: center; transition: border-color .12s; }- .card .orient-opt:hover { border-color: var(--accent); }- .card .orient-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }- .card .orient-opt img { width: 130px; height: 130px; object-fit: contain; display: block; margin: 0 auto 2px; }- .card .orient-opt span { font-size: 11px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }- aside { background: var(--panel); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; }- aside.empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }- /* Tabbed detail panel — keeps the 66-file list from dominating. */- .dtabs { display: flex; gap: 4px; margin: 14px 0 12px; border-bottom: 1px solid var(--border); }- .dtab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-size: 13px; font-weight: 600; padding: 7px 10px; cursor: pointer; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 5px; }- .dtab:hover { color: var(--text, #e6edf3); }- .dtab.on { color: var(--accent, #00b8b1); border-bottom-color: var(--accent, #00b8b1); }- .dtab .dtab-n { font-size: 10px; background: var(--panel2, #0f141c); border: 1px solid var(--border); border-radius: 8px; padding: 0 5px; color: var(--muted); }- .dpanel { display: none; }- .dpanel.on { display: block; }- .filelist { margin: 0; padding: 0; list-style: none; max-height: calc(100vh - 220px); overflow-y: auto; }- .filelist li { padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04); }- .filelist a { color: var(--accent); text-decoration: none; font-size: 11.5px; word-break: break-all; }- aside h2 { margin: 0 0 8px; font-size: 18px; }- aside .vendor { color: var(--muted); margin-bottom: 16px; }- aside .section { margin-bottom: 18px; }- aside .section-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }- aside ul { margin: 0; padding-left: 0; list-style: none; }- aside ul li { padding: 4px 0; border-bottom: 1px solid var(--border); }- aside ul li:last-child { border-bottom: none; }- aside a { color: var(--accent); text-decoration: none; }- aside a:hover { text-decoration: underline; }- aside pre { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; font-size: 11px; color: var(--muted); overflow-x: auto; max-height: 300px; }- aside .fields { display: flex; flex-direction: column; gap: 0; }- aside .field { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border-muted); font-size: 12px; align-items: baseline; }- aside .field:last-child { border-bottom: none; }- aside .field-key { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; letter-spacing: 0.02em; }- aside .field-val { color: var(--text); font-family: 'Satoshi', sans-serif; word-break: break-word; }- aside .field-val a { color: var(--accent); }- aside .field.unknown .field-val { color: var(--text3); font-style: italic; font-family: 'JetBrains Mono', monospace; font-size: 11px; }- .empty-state { color: var(--muted); padding: 40px; text-align: center; }- .card .card-desc { color: var(--text2); font-size: 11px; line-height: 1.35; margin: 4px 0 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }- .out-of-filter-note { grid-column: 1 / -1; background: rgba(230,180,80,.10); border: 1px solid rgba(230,180,80,.35); color: var(--text2); border-radius: 6px; padding: 8px 12px; font-size: 12px; display: flex; align-items: center; gap: 10px; }- .out-of-filter-note b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }- .out-of-filter-note .ofn-clear { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-left: auto; }- .out-of-filter-note .ofn-clear:hover { filter: brightness(1.1); }- .activity { display: none; padding: 10px 24px; background: rgba(0,184,176,.10); border-bottom: 1px solid var(--accent); font-size: 13px; color: var(--text); align-items: center; gap: 12px; }- .activity.live { display: flex; }- /* Stale: the last heartbeat is old enough that no work is actually- happening. Show it muted with the age, instead of a permanent WORKING. */- .activity.stale { display: flex; background: rgba(120,130,140,.07); border-bottom-color: var(--border); }- .activity.stale .dot { background: var(--text3); animation: none; }- .activity.stale .mpn-tag { color: var(--text3); }- .activity .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }- .activity .label { color: var(--muted); }- .activity .mpn-tag { font-family: "JetBrains Mono", monospace; font-weight: 600; color: var(--accent); }- .activity .stage { color: var(--muted); }- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }- .card.active { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,.45); animation: cardpulse 1.2s ease-in-out infinite; position: relative; }- .card.active::after { content: "WORKING"; position: absolute; top: -8px; right: 8px; background: var(--accent); color: white; font-size: 9px; padding: 2px 6px; border-radius: 3px; letter-spacing: .5px; font-weight: 700; }- @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 3px rgba(0,184,176,.45); } 50% { box-shadow: 0 0 0 6px rgba(0,184,176,.20); } }- /* --- Project tabs --- */- .project-tabs { display: flex; flex-wrap: wrap; }- .project-tab {- font: 500 11px/1 'JetBrains Mono', monospace;- padding: 4px 10px; border-radius: 4px; cursor: pointer;- background: var(--surface); border: 1px solid var(--border-muted);- color: var(--text2); transition: all 0.15s;- }- .project-tab:hover { background: var(--elevated); color: var(--text); }- .project-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }- .project-tab .count { opacity: 0.7; margin-left: 4px; }+/* Adom brand fonts. JetBrains Mono is not hosted on adom.inc (404), so the 400/500 files come from fontsource. */+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');font-weight:600;font-display:swap}+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+@font-face{font-family:'JetBrains Mono';src:url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+@font-face{font-family:'JetBrains Mono';src:url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}++:root{+ --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;+ --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--text-3:#484f58;+ --accent:#00b8b1;--accent-hover:#00d4cb;--accent-bright:#00e6dc;--on-accent:#05221f;+ --accent-10:rgba(0,184,177,.10);--accent-20:rgba(0,184,177,.20);--accent-35:rgba(0,184,177,.35);+ --green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;--purple:#8c6bf7;+ --green-10:rgba(63,185,80,.12);--green-35:rgba(63,185,80,.35);--red-10:rgba(248,81,73,.12);--red-35:rgba(248,81,73,.35);+ --yellow-10:rgba(210,153,34,.14);--yellow-35:rgba(210,153,34,.35);--neutral-10:rgba(139,148,158,.12);--purple-10:rgba(140,107,247,.14);--blue-10:rgba(100,171,255,.12);+ --scrim:rgba(13,17,23,.72);--shadow:0 12px 40px rgba(0,0,0,.55);+ --thumb-3d-bg:#404b55; /* pyrender's iso-pose backdrop; must match the baked PNG canvas */+ --row-title:#8fd3d4; /* settings-layout: the intentional light-teal row title */+ --brand-heading:'Familjen Grotesk',sans-serif;--brand-body:'Satoshi',sans-serif;--mono:'JetBrains Mono',monospace;+ --z-hud:50;--z-toolbar:80;--z-floating-menu:200;--z-toast:400;--z-modal:600;--z-tooltip:99999;+}+*{box-sizing:border-box;margin:0;padding:0}+html,body{height:100%}+body{background:var(--bg);color:var(--text-body);font:13px/1.45 var(--brand-body);-webkit-tap-highlight-color:transparent}+/* House scrollbar (scrollbars skill): thin, transparent track, translucent pill, teal on hover. */+*{scrollbar-width:thin;scrollbar-color:rgba(139,148,158,.38) transparent}+::-webkit-scrollbar{width:10px;height:10px}+::-webkit-scrollbar-track{background:transparent}+::-webkit-scrollbar-thumb{background:rgba(139,148,158,.32);border-radius:999px;border:2px solid transparent;background-clip:padding-box}+::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box}+::-webkit-scrollbar-corner{background:transparent}+/* Input feedback (input-feedback skill): hover one step up, press compresses instantly, keyboard ring. */+button{font-family:inherit;color:inherit;cursor:pointer}+button,select,input,.pill,.card,.choice,.say-item,.tpill{transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease,transform .06s ease}+button:active:not(:disabled){transform:translateY(1px) scale(.97)}+:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+input:focus-visible,select:focus-visible{outline:none;border-color:var(--accent)}+button:disabled{opacity:.5;cursor:not-allowed}+a{color:var(--accent)}+code{font-family:var(--mono);font-size:.92em}+[hidden]{display:none !important}++/* ---- App shell ---- */+.app{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--bg)}+header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;border-bottom:1px solid var(--border);background:var(--surface);position:relative;z-index:var(--z-toolbar);font-family:var(--brand-heading)}+.brand{display:flex;align-items:center;gap:8px;color:var(--accent);font-size:13px;font-weight:600;letter-spacing:.01em;cursor:default;flex-shrink:0;white-space:nowrap}+.brand svg.logo{width:16px;height:16px;flex-shrink:0;color:var(--text)}+.brand .app-subtitle{color:var(--text-2);font-weight:500}+.brand .sep{color:var(--text-3);font-weight:400}+.vsep{width:1px;height:18px;background:var(--border);flex-shrink:0}+.subject{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:default}+.subject-name{color:var(--text);font:600 14px var(--brand-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.subject-name.idle{color:var(--text-2);font-weight:500}+.state-pill{display:none;font:600 11px/1 var(--brand-heading);padding:4px 9px;border-radius:999px;flex-shrink:0;border:1px solid transparent}+.state-pill.show{display:inline-flex}+.state-pill.full{background:var(--green-10);color:var(--green);border-color:var(--green-35)}+.state-pill.stub{background:var(--yellow-10);color:var(--yellow);border-color:var(--yellow-35)}+.state-pill.fetching{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35);animation:pillpulse 1.2s ease-in-out infinite}+@keyframes pillpulse{0%,100%{opacity:1}50%{opacity:.55}}+.subject-meta{display:flex;align-items:center;gap:6px;color:var(--text-2);font:11px var(--mono);white-space:nowrap}+.subject-meta .sep{color:var(--text-3)}+.actions{display:flex;align-items:center;gap:4px;margin-left:auto;flex-shrink:0}+.iconbtn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-2);padding:0}+.iconbtn svg{width:18px;height:18px}+.iconbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--text)}+.iconbtn:active:not(:disabled){transform:scale(.9);background:var(--overlay)}+.iconbtn.is-on{background:var(--accent-20);border-color:var(--accent-35);color:var(--accent)}+.hover-popover{position:absolute;top:calc(100% + 6px);left:16px;background:var(--elevated);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);padding:12px 14px;min-width:300px;max-width:min(440px,calc(100vw - 32px));font:12px var(--brand-body);color:var(--text-body);z-index:var(--z-tooltip);opacity:0;transform:translateY(-2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease}+.hover-popover.show{opacity:1;transform:none;pointer-events:auto}+.popover-title{font:700 14px var(--brand-heading);color:var(--text);margin-bottom:8px}+.popover-row{display:flex;gap:12px;padding:3px 0;align-items:baseline}+.popover-row .k{color:var(--text-2);font:11px var(--mono);width:64px;flex-shrink:0}+.popover-row .v{color:var(--text-body);flex:1;line-height:1.5}+.popover-actions{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:14px}+.popover-link{color:var(--accent);text-decoration:none;font:600 12px var(--brand-heading);display:inline-flex;align-items:center;gap:4px}+.popover-link svg{width:12px;height:12px}+.popover-link:hover{text-decoration:underline;text-underline-offset:3px}++/* ---- Filter row ---- */+.filterbar{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--bg);flex-wrap:wrap}+.filterbar input[type=search],.filterbar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:8px;font:12.5px var(--brand-body);min-height:30px}+.filterbar input[type=search]{min-width:220px;flex:1 1 220px;max-width:380px}+.filterbar input::placeholder{color:var(--text-2)}+.filterbar select{color-scheme:dark}+.filterbar input:hover,.filterbar select:hover{border-color:var(--text-2)}+.project-pills{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-left:auto}+.tpill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);font:500 12px var(--brand-body);min-height:28px}+.tpill .n{font:11px var(--mono);color:var(--text-2)}+.tpill:hover{border-color:var(--text-2);background:var(--elevated);color:var(--text)}+.tpill.active{border-color:var(--accent);background:var(--accent-10);color:var(--text)}+.tpill.active .n{color:var(--accent)}++/* ---- Body: content scroller + detail aside ---- */+.app-body{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 380px}+.app-body.noaside{grid-template-columns:minmax(0,1fr)}+.app-body.noaside aside{display:none}+.content{min-height:0;overflow-y:auto;padding:16px 20px 40px;scrollbar-gutter:stable}+.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}+.empty-state{color:var(--text-2);padding:40px;text-align:center;grid-column:1/-1}++/* ---- Say it strip (copied from the KiCad dashboard so every Adom app teaches the same way) ---- */+.say{background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:14px 16px;margin-bottom:12px}+.say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:10px}+.say-head h2{font:600 17px var(--brand-heading);color:var(--text);margin:0}+.say-sub{font-size:12.5px;color:var(--text-2);max-width:62ch}+.say-sub b{color:var(--text);font-weight:600}+.say-list{display:flex;flex-wrap:wrap;gap:8px}+.say-item{display:inline-flex;align-items:stretch;border:1px solid var(--border);border-radius:999px;background:var(--elevated);overflow:hidden}+.say-item:hover{border-color:var(--accent)}+.say-phrase{display:inline-block;line-height:1.3;padding:7px 12px 7px 14px;background:none;border:none;color:var(--text);font:500 14px var(--brand-body);text-align:left}+.say-phrase em{font-style:normal;color:var(--accent)}+.say-phrase:hover{background:var(--overlay)}+.say-try{display:inline-flex;align-items:center;white-space:nowrap;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading)}+.say-try:hover{background:var(--overlay);color:var(--accent)}+.say-phrase:focus-visible,.say-try:focus-visible{outline-offset:-2px}++/* ---- Component LEDs (compact row, always visible) ---- */+.leds{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}+.ledchip{display:inline-flex;align-items:center;gap:8px;padding:5px 11px 5px 9px;border:1px solid var(--border);border-radius:999px;background:var(--surface);font-size:12px;color:var(--text-body);cursor:default;min-height:28px}+.ledchip .lab{font-weight:500;color:var(--text)}+.ledchip .det{color:var(--text-2);font-size:11.5px}+.led{width:9px;height:9px;border-radius:999px;background:var(--text-2);flex:none}+.led.ok{background:var(--green);box-shadow:0 0 6px rgba(63,185,80,.5)}+.led.warn{background:var(--yellow)}+.led.fail{background:var(--red)}++/* ---- Welcome (first run) ---- */+.welcome{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px;max-width:760px}+.welcome h2{font:600 18px var(--brand-heading);color:var(--text);margin-bottom:6px}+.welcome p{color:var(--text-2);margin-bottom:12px;max-width:62ch}+.welcome ol{list-style:none;display:flex;flex-direction:column;gap:10px}+.welcome li{display:flex;gap:12px;align-items:flex-start;line-height:1.5}+.welcome .num{flex:none;width:26px;height:26px;border-radius:999px;background:var(--accent-10);border:1px solid var(--accent-35);color:var(--accent);font:600 13px var(--brand-heading);display:inline-flex;align-items:center;justify-content:center}+.welcome .btn{margin-top:8px}++/* ---- Buttons ---- */+.btn{display:inline-flex;align-items:center;gap:8px;min-height:32px;padding:0 13px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 13px var(--brand-body)}+.btn svg{width:16px;height:16px}+a.btn{text-decoration:none}+.btn:hover{background:var(--overlay);border-color:var(--text-2)}+.btn:active:not(:disabled){transform:translateY(1px) scale(.98);background:var(--surface)}+.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}+.btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover)}+.btn.danger{color:var(--red);border-color:var(--red-35)}+.btn.danger:hover{background:var(--red-10)}+.btn.small{min-height:26px;padding:0 9px;font-size:12px}++/* ---- Cards ---- */+.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;cursor:pointer;position:relative}+.card:hover{border-color:var(--text-2)}+.card:active{transform:scale(.995)}+.card.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}+.card.active{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-35)}+.mpn-row{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:2px}+.mpn{font:600 15px/1.3 var(--brand-heading);color:var(--text);overflow-wrap:anywhere;flex:1;min-width:0;padding-top:3px}+.card-actions{display:flex;gap:1px;align-items:center;flex:0 0 auto}+.cbtn{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:7px;color:var(--text-2);padding:0}+.cbtn svg{width:15px;height:15px}+.cbtn:hover{color:var(--text);background:var(--elevated);border-color:var(--border)}+.cbtn:active:not(:disabled){transform:scale(.9)}+.cbtn:disabled{cursor:wait}+.card .meta{color:var(--text-2);font-size:12px;margin-bottom:6px}+.card-desc{color:var(--text-2);font-size:11.5px;line-height:1.4;margin:2px 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}+.thumbs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:4px 0 10px}+.thumb{background:var(--bg);border:1px solid var(--border);border-radius:8px;aspect-ratio:1/1;display:flex;align-items:center;justify-content:center;padding:8%;overflow:hidden;position:relative}+.thumb[data-thumb-kind^="chip3d"]{background:var(--thumb-3d-bg);padding:4%}+.thumb:not(.thumb-static){cursor:pointer}+.thumb:not(.thumb-static):hover{border-color:var(--accent)}+.thumb img{width:100%;height:100%;object-fit:contain;display:block}+.thumb-label{position:absolute;bottom:4px;right:6px;font:11px var(--mono);color:var(--text-2);letter-spacing:.04em;text-shadow:0 1px 2px rgba(0,0,0,.6);pointer-events:none}+.thumb.empty{border-style:dashed}+.thumb.empty .thumb-placeholder{color:var(--text-2);font:11px var(--mono)}+.thumb.failed{border-style:dashed;border-color:var(--red-35);flex-direction:column;gap:2px;background:var(--red-10)}+.thumb.failed .thumb-failed{color:var(--red);font:600 11px var(--mono)}+.thumb.failed .thumb-failed-sub{color:var(--text-2);font:11px var(--mono)}+.thumb.refreshing img{opacity:.3;transition:opacity .2s}+.thumb-spinner{position:absolute;top:50%;left:50%;width:22px;height:22px;margin:-11px 0 0 -11px;border:2px solid rgba(230,237,243,.22);border-top-color:var(--accent);border-radius:50%;animation:thumbspin .7s linear infinite;pointer-events:none;z-index:4}+@keyframes thumbspin{to{transform:rotate(360deg)}}+.thumb-src-group{position:relative;display:flex}+.thumb-src-group .thumb{flex:1}+.src-chip{position:absolute;top:3px;left:3px;z-index:4;display:inline-flex;align-items:center;gap:4px;font:600 11px var(--brand-body);color:var(--text-body);background:rgba(13,17,23,.85);border:1px solid var(--border);border-radius:999px;padding:1px 6px;max-width:92%;line-height:1.3}+.src-chip:hover{border-color:var(--accent);background:var(--accent-10)}+.src-chip-lab{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.src-chip svg{width:10px;height:10px;flex:none;color:var(--text-2)}+.src-dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px;background:var(--text-2)}+.src-dot.vendor{background:var(--accent)}+.src-dot.ds{background:var(--yellow)}+.thumb-3d-group{position:relative}+.thumb-3d-group .thumb{width:100%}+.orient-more-btn{position:absolute;bottom:4px;left:4px;z-index:5;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:rgba(13,17,23,.85);border:1px solid var(--border);color:var(--text-2);border-radius:6px;padding:0}+.orient-more-btn svg{width:13px;height:13px}+.orient-more-btn:hover{color:var(--accent);border-color:var(--accent)}+.card-foot{display:flex;align-items:center;gap:6px;flex-wrap:wrap}+.pill{display:inline-flex;align-items:center;gap:4px;font:600 11px/1 var(--brand-body);padding:4px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap;cursor:default}+.pill svg{width:11px;height:11px}+.pill.ok{background:var(--green-10);color:var(--green);border-color:var(--green-35)}+.pill.warn{background:var(--yellow-10);color:var(--yellow);border-color:var(--yellow-35)}+.pill.bad{background:var(--red-10);color:var(--red);border-color:var(--red-35)}+.pill.accent{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35)}+.pill.unk{background:var(--neutral-10);color:var(--text-2);border:1px dashed var(--border)}+.pill.fetching{animation:pillpulse 1.2s ease-in-out infinite}+.mini-actions{margin-left:auto;display:flex;gap:4px}+.pill-act{display:inline-flex;align-items:center;gap:4px;font:500 11px var(--brand-body);padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:none;color:var(--text-2);min-height:22px}+.pill-act svg{width:12px;height:12px}+.pill-act:hover{color:var(--accent);border-color:var(--accent)}+.out-of-filter-note{grid-column:1/-1;background:var(--yellow-10);border:1px solid var(--yellow-35);color:var(--text-body);border-radius:8px;padding:8px 12px;font-size:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}+.out-of-filter-note b{color:var(--accent);font-family:var(--mono)}+.out-of-filter-note .btn{margin-left:auto}++/* ---- Floating menus (refetch, orientation, source picker): one body-level element at the floating-menu rung ---- */+.floating{position:fixed;z-index:var(--z-floating-menu);background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:6px;box-shadow:var(--shadow);max-width:min(560px,calc(100vw - 16px));max-height:min(70vh,520px);overflow:auto}+.floating .fhead{font:600 11px var(--brand-body);color:var(--text-2);padding:6px 10px 4px}+.fopt{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:1px solid transparent;border-radius:7px;padding:7px 10px;color:var(--text-body);font:13px var(--brand-body);min-height:32px}+.fopt:hover{background:var(--elevated);border-color:var(--border);color:var(--text)}+.fopt .fsub{color:var(--text-2);font-size:11.5px;margin-left:auto}+.fopt svg{width:14px;height:14px;color:var(--text-2);flex:none}+.fgal{display:flex;flex-wrap:wrap;gap:8px;padding:4px}+.gopt{display:flex;flex-direction:column;align-items:center;gap:6px;border:1px solid var(--border);border-radius:8px;padding:8px;width:152px;background:var(--surface);color:var(--text-body);font:12px var(--brand-body);text-align:center}+.gopt:hover{border-color:var(--accent);background:var(--elevated)}+.gopt.chosen{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}+.gopt img{width:132px;height:132px;object-fit:contain;background:var(--bg);border-radius:6px;flex:none}+.gopt.g3d img{background:var(--thumb-3d-bg)}+.gopt .glab{line-height:1.3;white-space:normal}+.gopt .gcur{color:var(--green);font-weight:600;display:inline-flex;align-items:center;gap:3px;font-size:11px}+.gopt .gcur svg{width:11px;height:11px}++/* ---- Card zoom ---- */+.card-zoom-backdrop{position:fixed;inset:0;z-index:var(--z-modal);background:rgba(13,17,23,0);cursor:pointer;transition:background .25s ease-out}+.card-zoom-backdrop.visible{background:var(--scrim)}+.card-zoomed-wrapper{position:fixed;z-index:1;pointer-events:auto;cursor:default;transition:top .3s ease-out,left .3s ease-out,transform .3s ease-out}+.card-zoomed-wrapper .card{transform-origin:top left;border:2px solid var(--accent);box-shadow:0 24px 80px rgba(0,0,0,.7);cursor:default}+.card-zoomed-wrapper .card:active{transform:none}+.card-zoom-x{position:fixed;top:12px;right:12px;z-index:2;width:44px;height:44px;border-radius:50%;border:1px solid var(--border);background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;box-shadow:var(--shadow)}+.card-zoom-x svg{width:20px;height:20px}+.card-zoom-x:hover{border-color:var(--accent);color:var(--accent)}+.card-zoom-backdrop.fullscreen{overflow-y:auto;-webkit-overflow-scrolling:touch;display:flex;align-items:flex-start;justify-content:center;padding:64px 0 36px}+.card-zoom-backdrop.fullscreen .card-zoomed-wrapper{position:relative !important;left:auto !important;top:auto !important}+.card.zoom-fluid{display:grid;grid-template-columns:minmax(320px,1.25fr) minmax(300px,1fr);column-gap:26px;align-items:start;padding:22px}+.card.zoom-fluid .zoom-col-media,.card.zoom-fluid .zoom-col-info{min-width:0;display:flex;flex-direction:column;gap:10px}+.card.zoom-fluid .thumbs{margin:0}+.card-zoom-backdrop .card-desc{-webkit-line-clamp:unset;display:block;overflow:visible;font-size:14px;line-height:1.5;color:var(--text-body);margin:6px 0 12px;max-width:60ch}+.zoom-status{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:3;background:var(--surface);border:1px solid var(--accent-35);border-radius:999px;padding:8px 18px;font:600 13px var(--brand-body);color:var(--accent);box-shadow:var(--shadow);white-space:nowrap;display:inline-flex;align-items:center;gap:8px}+.zoom-status .led{background:var(--accent);animation:pillpulse 1.2s ease-in-out infinite}+.zoom-actions{margin-top:14px;border-top:1px solid var(--border);padding-top:12px}+.zoom-sec{font:600 11px var(--brand-heading);color:var(--text-2);margin:0 0 6px}+.zoom-actions .openin-row,.zoom-actions .sendto-row{margin-bottom:12px}+.openin-row,.sendto-row{display:flex;gap:6px;flex-wrap:wrap}+.openbtn,.sendbtn{background:var(--elevated);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:0 11px;min-height:30px;font:600 12px var(--brand-body);display:inline-flex;align-items:center;gap:6px}+.openbtn:hover{background:var(--accent-10);border-color:var(--accent);color:var(--accent)}+.sendbtn:hover{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}+.openbtn:disabled,.sendbtn:disabled{cursor:progress}++/* ---- Detail aside ---- */+aside{background:var(--surface);border-left:1px solid var(--border);padding:18px 20px;overflow-y:auto;min-height:0}+aside.empty{display:flex;align-items:center;justify-content:center;color:var(--text-2);text-align:center;padding:24px}+.aside-head{display:flex;align-items:flex-start;gap:10px}+.aside-head h2{font:600 18px/1.25 var(--brand-heading);color:var(--text);margin:0 0 6px;word-break:break-all;flex:1}+.aside-close{display:none}+aside .vendor{color:var(--text-2);margin-bottom:12px}+.dtabs{display:flex;gap:4px;margin:10px 0 12px;border-bottom:1px solid var(--border)}+.dtab{background:none;border:none;border-bottom:2px solid transparent;color:var(--text-2);font:600 13px var(--brand-heading);padding:7px 10px;margin-bottom:-1px;display:inline-flex;align-items:center;gap:6px;border-radius:6px 6px 0 0;min-height:34px}+.dtab:hover{color:var(--text);background:var(--elevated)}+.dtab.on{color:var(--accent);border-bottom-color:var(--accent)}+.dtab:active:not(:disabled){transform:none}+.dtab-n{font:11px var(--mono);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:1px 6px;color:var(--text-2)}+.dpanel{display:none}+.dpanel.on{display:block}+.section{margin-bottom:18px}+.section-title{font:600 11px var(--brand-heading);color:var(--text-2);margin-bottom:6px}+.filelist{margin:0;padding:0;list-style:none}+.filelist li{padding:4px 0;border-bottom:1px solid var(--border)}+.filelist li:last-child{border-bottom:none}+.filelist a{color:var(--accent);text-decoration:none;font-size:11.5px;word-break:break-all}+.filelist a:hover{text-decoration:underline}+.fields{display:flex;flex-direction:column}+.field{display:grid;grid-template-columns:120px 1fr;gap:10px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px;align-items:baseline}+.field:last-child{border-bottom:none}+.field-key{color:var(--text-2);font:11px var(--mono)}+.field-val{color:var(--text-body);word-break:break-word}+.field.unknown .field-val{color:var(--text-2);font-style:italic}+.pills{display:flex;gap:6px;flex-wrap:wrap}++/* ---- Validation view ---- */+.view{padding:4px 0}+.vhead{display:flex;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap}+.vtitle{font:600 18px var(--brand-heading);color:var(--text)}+.vsum{font:600 13px var(--brand-heading);padding:5px 12px;border-radius:999px;border:1px solid}+.vsum.ok{background:var(--green-10);color:var(--green);border-color:var(--green-35)}+.vsum.bad{background:var(--red-10);color:var(--red);border-color:var(--red-35)}+.vnote{font-size:12px;color:var(--text-2)}+.vscroll{overflow-x:auto}+.vtable{border-collapse:collapse;width:100%;min-width:760px}+.vtable th{font:600 11px var(--brand-body);color:var(--text-2);padding:4px 6px;text-align:center;white-space:nowrap}+.vtable th.l,.vtable td.l{text-align:left}+.vtable th .sub{display:block;font:11px var(--mono);color:var(--accent);font-weight:400}+.vtable td{padding:5px 6px;text-align:center;border-top:1px solid var(--border)}+.vtable td.mpn{font:12px var(--mono);color:var(--text);white-space:nowrap;padding-right:14px}+.vchk{display:inline-flex;align-items:center;justify-content:center;color:var(--green);vertical-align:middle}+.vchk svg{width:15px;height:15px}+.vchk.bad{color:var(--red)}+.vchk .star{color:var(--accent);font-weight:700;margin-left:1px}+.vcon{font:600 12px var(--brand-heading)}+.vcon.ok{color:var(--green)}.vcon.bad{color:var(--red)}+.vcon-sub{display:block;font:11px var(--mono);color:var(--text-2);font-weight:400;margin-top:2px}+.vverdict{font:600 12px var(--brand-heading);white-space:nowrap}+.vverdict.ok{color:var(--green)}.vverdict.bad{color:var(--red)}+.vsub td{padding:4px 6px 4px 30px;background:var(--accent-10);text-align:left}+.vsub .arrow{color:var(--accent);font:11px var(--mono)}+.vsub .slabel{color:var(--text-body);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px}+.vsub .k{font:600 11px var(--brand-body);color:var(--text-2);margin-left:18px}+.vsub .k:first-of-type{margin-left:0}+.vdl{display:inline-flex;align-items:center;gap:4px;font:600 11px var(--brand-body);text-decoration:none;border-radius:999px;padding:2px 8px;margin-left:6px;white-space:nowrap;color:var(--accent);border:1px solid var(--accent-35)}+.vdl svg{width:11px;height:11px}+.vdl.orig{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}+.vdl:hover{border-color:var(--accent)}+.vdls{margin-left:22px;display:inline-flex;align-items:center;flex-wrap:wrap;gap:4px 0}++/* ---- Sourcing order view ---- */+.srcwrap{max-width:920px}+.srctitle{font:600 20px var(--brand-heading);color:var(--text);margin-bottom:4px}+.srcsub{font-size:12.5px;color:var(--text-2);margin-bottom:18px;line-height:1.5}+.srcsub b{color:var(--text-body)}+.src-concern{margin-bottom:22px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px 18px}+.src-concern h3{font:600 16px var(--brand-heading);color:var(--text)}+.src-what{font-size:12.5px;color:var(--text-2);margin:4px 0 8px}+.src-rule{font-size:12px;color:var(--accent);background:var(--accent-10);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px}+.src-step{display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)}+.src-n{font:500 13px var(--mono);color:var(--accent);min-width:22px;text-align:right}+.src-tier{min-width:118px}+.tier{display:inline-block;font:600 11px var(--brand-body);padding:3px 8px;border-radius:999px;background:var(--neutral-10);color:var(--text-body);border:1px solid var(--border);white-space:nowrap}+.tier.t1{background:var(--accent-10);color:var(--accent);border-color:var(--accent-35)}+.tier.t2{background:var(--purple-10);color:var(--purple)}+.tier.t3{background:var(--blue-10);color:var(--blue)}+.tier.t4{background:var(--red-10);color:var(--red);border-color:var(--red-35)}+.src-body{flex:1;min-width:0}+.src-name{font:600 14px var(--brand-heading);color:var(--text)}+.src-detail{font-size:12.5px;color:var(--text-2);line-height:1.45;margin-top:2px}+.src-cmd{display:inline-block;margin-top:5px;font:11.5px var(--mono);color:var(--accent);background:var(--accent-10);border:1px solid var(--accent-35);border-radius:5px;padding:2px 7px}+.src-skill{font-size:11.5px;color:var(--text-2);margin-left:6px}++/* ---- Architecture overlay ---- */+#arch-overlay{position:fixed;inset:0;z-index:var(--z-modal);background:var(--scrim);display:flex;flex-direction:column}+.arch-bar{display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:44px;border-bottom:1px solid var(--border);background:var(--surface)}+.arch-bar .t{font:600 14px var(--brand-heading);color:var(--text)}+.arch-bar .s{color:var(--text-2);font-size:12px}+.arch-bar a{margin-left:auto;color:var(--text-2);font-size:12px;text-decoration:none;display:inline-flex;align-items:center;gap:4px}+.arch-bar a svg{width:12px;height:12px}+.arch-bar a:hover{color:var(--accent)}+#arch-overlay iframe{flex:1;width:100%;border:0;background:var(--bg)}++/* ---- Dialogs (bridge, settings) ---- */+dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:0;max-width:none;box-shadow:var(--shadow)}+dialog:focus{outline:none}+/* Backdrop law: dim 20-30%, never blur, one value for every dialog in the app. */+dialog::backdrop{background:rgba(0,0,0,.25)}+#bridgeModal{width:min(94vw,460px)}+.bm-card{padding:22px 22px 18px;text-align:center}+.bm-icon{display:inline-flex;width:44px;height:44px;align-items:center;justify-content:center;border-radius:12px;background:var(--accent-10);color:var(--accent);margin-bottom:8px}+.bm-icon svg{width:24px;height:24px}+.bm-title{font:600 16px var(--brand-heading);color:var(--text);margin-bottom:8px}+.bm-body{font-size:13px;line-height:1.55;color:var(--text-2);margin-bottom:14px}+.bm-hint{font-size:12px;line-height:1.5;color:var(--text-body);background:var(--accent-10);border:1px solid var(--accent-35);border-radius:9px;padding:8px 10px;margin-bottom:14px;text-align:left}+.bm-actions{display:flex;flex-direction:column;gap:9px}+.bm-actions .btn{justify-content:center}++dialog.settings{width:min(680px,92vw);overflow:hidden}+.settings-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-bottom:1px solid var(--border);cursor:move;user-select:none}+.settings-head h2{margin:0;font:600 15px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px}+.settings-head .iconbtn{width:32px;height:32px}+.settings-body{max-height:70vh;overflow:auto;padding:18px}+.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem;scroll-margin-top:.75rem}+.card-title{font:600 13px/1.3 var(--brand-heading);color:var(--text);display:flex;align-items:center;gap:10px;margin:0 0 .45rem}+.card-status{font:400 11px var(--brand-body);color:var(--text-2)}+.card-status.ok{color:var(--green)}+.card-status.warn{color:var(--yellow)}+.card-status.bad{color:var(--red)}+.card-desc-s{font:12px/1.55 var(--brand-body);color:var(--text-2);max-width:60ch;margin:0 0 .55rem}+.card-desc-s code{font:11px var(--mono);background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px;color:var(--text-body)}+.divider{height:1px;background:var(--border);margin:.55rem 0}+.wrapper{padding:.5rem 0;display:flex;flex-direction:column;gap:.3rem}+.name{margin:.125rem 0;font:600 .9375rem var(--brand-heading)}+.prefix{color:var(--text)}+.title{color:var(--row-title)}+.default-hint{font:.75rem var(--brand-body);color:var(--text-2)}+.setting-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}+.set-check-label{font:.875rem var(--brand-body);color:var(--text-body)}+.row-desc{font:12px/1.5 var(--brand-body);color:var(--text-2);max-width:60ch}+.set-input{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 10px;font:13px var(--brand-body);min-height:32px;color-scheme:dark}+.set-input.num{width:110px;font-family:var(--mono)}+.toggle{background:none;border:none;padding:2px;flex-shrink:0}+.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}+.toggle:hover .toggle-track{border-color:var(--accent)}+.toggle.is-on .toggle-track{background:var(--accent);border-color:var(--accent)}+.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}+.toggle.is-on .toggle-thumb{transform:translateX(14px)}+.toggle:active .toggle-thumb{width:16px}+.toggle.is-on:active .toggle-thumb{transform:translateX(10px)}+.toggle:focus-visible{border-radius:10px}+.comp-row{display:flex;align-items:center;gap:10px;padding:.45rem 0;flex-wrap:wrap}+.comp-row .lab{font:600 13px var(--brand-heading);color:var(--text);min-width:96px}+.comp-row .det{color:var(--text-2);font-size:12px;flex:1;min-width:140px}+.console{display:none;margin:.25rem 0 .5rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font:11.5px/1.5 var(--mono);color:var(--text-body);max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word}+.console.show{display:block}+.console .exit{color:var(--text-2)}+.console .exit.ok{color:var(--green)}.console .exit.bad{color:var(--red)}+.choice-set{border:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}+.choice{flex:1 1 240px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;position:relative;background:var(--surface);border:1px solid var(--border);border-radius:8px}+.choice input{position:absolute;opacity:0;pointer-events:none}+.choice-shot{display:flex;align-items:center;justify-content:center;height:72px;border:1px dashed var(--border);border-radius:6px;background:var(--bg);color:var(--text-2);font:11.5px var(--brand-body)}+.choice-name{font:600 .875rem var(--brand-heading);color:var(--text)}+.choice-desc{font:.75rem/1.5 var(--brand-body);color:var(--text-2)}+.choice:hover{border-color:var(--accent);background:var(--elevated)}+.choice:active{transform:translateY(1px) scale(.995)}+.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}+.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}+.cred-list{display:flex;flex-direction:column;gap:6px;margin:.4rem 0}+.cred-row{display:flex;align-items:center;gap:10px;padding:7px 10px;background:var(--surface);border:1px solid var(--border);border-radius:8px}+.cred-row code{color:var(--text);font-size:12px}+.cred-row .u{color:var(--text-2);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}+.cred-row.pending{opacity:.4}+.inline-err{font-size:12px;color:var(--red);background:var(--red-10);border:1px solid var(--red-35);border-radius:8px;padding:8px 10px;margin:.4rem 0;line-height:1.5}+.inline-err .h{color:var(--text-body);display:block;margin-top:4px}+.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}+.footer-note{font:.75rem var(--brand-body);color:var(--text-2);margin:0;max-width:60ch}+.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font:600 .8125rem var(--brand-heading)}+.restore-btn svg{width:18px;height:18px}+.restore-btn:hover{background:var(--elevated)}+.restore-btn.armed{color:var(--red);border-color:var(--red-35);background:var(--red-10)}++/* ---- Toasts (toasts skill): bottom center, stack newest on top, three types, pause on hover ---- */+#toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:var(--z-toast);max-width:min(520px,calc(100vw - 24px));pointer-events:none}+.toast{display:flex;align-items:center;gap:10px;pointer-events:auto;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;padding:10px 12px 10px 12px;color:var(--text);font-size:13px;box-shadow:var(--shadow);animation:toastin .18s ease}+@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}+.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}+.toast .ticon{flex:none;display:inline-flex;color:var(--accent)}+.toast.ok .ticon{color:var(--green)}.toast.err .ticon{color:var(--red)}+.toast .ticon svg{width:16px;height:16px}+.toast .tmsg{flex:1;min-width:0;word-break:break-word}+.toast .taction{background:none;border:none;color:var(--blue);font:600 13px var(--brand-body);padding:2px 6px;border-radius:6px;white-space:nowrap}+.toast .taction:hover{background:var(--elevated)}+.toast .tx{background:none;border:none;color:var(--text-2);padding:2px;display:inline-flex;border-radius:6px}+.toast .tx svg{width:14px;height:14px}+.toast .tx:hover{color:var(--text);background:var(--elevated)}++/* ---- Tooltip (tooltips skill): one body-appended fixed div, cursor-relative, clamped both axes ---- */+#tt{position:fixed;z-index:var(--z-tooltip);top:0;left:0;transform:translate(var(--tip-x,0),var(--tip-y,0));max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;display:none;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:10px;padding:8px 12px;font:400 12.5px/1.45 var(--brand-body);box-shadow:var(--shadow);text-transform:none;letter-spacing:0;white-space:pre-wrap}+#tt.visible{display:block}+#tt.shown{opacity:1;transition:opacity .18s ease}+/* Touch: tap or long-press on a tooltip carrier opens this sheet instead (hover does not exist there). */+#infosheet{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-modal);background:var(--surface);border-top:1px solid var(--border);border-radius:14px 14px 0 0;padding:14px 18px calc(18px + env(safe-area-inset-bottom));box-shadow:0 -12px 40px rgba(0,0,0,.5);transform:translateY(100%);transition:transform .22s ease;font-size:14px;line-height:1.5;color:var(--text-body);white-space:pre-wrap}+#infosheet.open{transform:none}+#infosheet .btn{margin-top:12px}++/* ---- Phone (responsive-ui skill): reshape, not shrink ---- */+@media (max-width:720px){+ header{padding:0 8px 0 12px;gap:8px}+ .brand .app-subtitle,.brand .sep,.subject-meta{display:none}+ .actions{gap:2px}+ .iconbtn{width:32px;height:32px}+ .app-body{grid-template-columns:minmax(0,1fr)}+ .content{padding:12px 12px 120px}+ .filterbar{padding:8px 12px}+ .filterbar input[type=search]{min-width:0;flex:1 1 100%;max-width:none;min-height:40px}+ .filterbar select{flex:1 1 40%;min-height:40px}+ .project-pills{margin-left:0;width:100%;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;scrollbar-width:none}+ .project-pills::-webkit-scrollbar{display:none}+ .tpill{flex:none}+ .tpill{min-height:36px}+ aside{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-hud);max-height:72vh;border-left:none;border-top:1px solid var(--border);border-radius:14px 14px 0 0;box-shadow:0 -12px 40px rgba(0,0,0,.5);transform:translateY(100%);transition:transform .25s ease;padding-bottom:calc(18px + env(safe-area-inset-bottom))}+ aside.open:not(.empty){transform:none}+ aside.empty{display:none}+ .aside-close{display:inline-flex;width:36px;height:36px}+ .cbtn{width:36px;height:36px}+ .cbtn svg{width:17px;height:17px}+ .pill-act,.pill{min-height:28px;font-size:12px}+ .openbtn,.sendbtn,.btn{min-height:40px}+ .dtab{min-height:40px}+ dialog.settings{width:calc(100vw - 24px)}+ .settings-body{padding:14px}+ .toggle-track{width:40px;height:22px}+ .toggle-thumb{width:16px;height:16px}+ .toggle.is-on .toggle-thumb{transform:translateX(18px)}+ .toggle.is-on:active .toggle-thumb{transform:translateX(14px)}+ .card.zoom-fluid{display:block}+}+@media (max-width:480px){+ .brand .app-name{display:none}+ .vsep{display:none}+}+@media (hover:none),(pointer:coarse){+ #tt{display:none !important}+} </style> </head> <body>+<div class="app"> <header>- <div class="brand" data-tooltip-target="app-info" tabindex="0">- <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">- <rect x="6" y="6" width="12" height="12" rx="1.2"/>- <rect x="9" y="2" width="2" height="2"/><rect x="13" y="2" width="2" height="2"/>- <rect x="9" y="20" width="2" height="2"/><rect x="13" y="20" width="2" height="2"/>- <rect x="2" y="9" width="2" height="2"/><rect x="2" y="13" width="2" height="2"/>- <rect x="20" y="9" width="2" height="2"/><rect x="20" y="13" width="2" height="2"/>- </svg>- <span class="app-name">adom-chip-fetcher</span>- <span class="sep">·</span>+ <div class="brand" id="brand" tabindex="0" aria-describedby="app-info">+ <svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M9 7V4M12 7V4M15 7V4M9 20v-3M12 20v-3M15 20v-3M7 9H4M7 12H4M7 15H4M20 9h-3M20 12h-3M20 15h-3"/></svg>+ <span class="app-name">Chip Fetcher</span>+ <span class="sep">·</span> <span class="app-subtitle">manufacturer CAD bundles</span> </div>- <div class="vsep" aria-hidden="true"></div>- <div class="subject">- <span class="subject-name idle" id="subjectName">—</span>+ <span class="subject-name idle" id="subjectName">No chip selected</span> <span class="state-pill" id="subjectPill"></span> <span class="subject-meta" id="subjectMeta">- <span class="sep">·</span><span id="metaCards" title="Each tile below is a “card” — one part's full CAD bundle. Tell the AI e.g. “open the B2B-PH-K-S card.”">0 cards</span>- <span class="sep">·</span><span id="metaFull">0 full</span>- <span class="sep">·</span><span id="metaStubs">0 stubs</span>- <span class="sep">·</span><span id="metaSize">0 MB</span>+ <span class="sep">·</span><span id="metaCards" data-tooltip="Each tile below is a card: one part's full CAD bundle. Tell your AI, for example, open the B2B-PH-K-S card.">0 cards</span>+ <span class="sep">·</span><span id="metaFull" data-tooltip="Cards that have every core file: datasheet, STEP 3D, KiCad symbol and footprint, plus the Altium and Fusion exports.">0 full</span>+ <span class="sep">·</span><span id="metaStubs" data-tooltip="Cards still missing one or more core files. Use the re-fetch menu on a card to fill the gaps.">0 stubs</span>+ <span class="sep">·</span><span id="metaSize" data-tooltip="Total size of the library folder on disk.">0 MB</span> </span> </div>-- <div class="actions"></div>-- <!-- App-info popover (interactive tooltip — stays open on hover) -->+ <div class="actions">+ <button class="iconbtn" id="btn-validate" aria-pressed="false" aria-label="Validation" data-tooltip="Validation: a check or cross for every required file of every chip (datasheet, STEP, symbol, footprint and the three thumbnails). It goes green only when truly complete, so done cannot be faked. Click again to return to the cards."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg></button>+ <button class="iconbtn" id="btn-sources" aria-pressed="false" aria-label="Sourcing order" data-tooltip="Sourcing order: the exact priority chip fetcher uses to search the internet for electronics data. Manufacturer site first, then the Adom data tools (parts-search, Mouser, DigiKey, JLCPCB), then the manual CAD-crawl skills (SnapMagic, CSE, Ultra Librarian), with LCSC last. Click again to return to the cards."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg></button>+ <button class="iconbtn" id="btn-arch" aria-label="Architecture map" data-tooltip="Architecture map: every tool chip fetcher depends on. Local CLIs that run in your container, native Rust libraries, remote service containers called over HTTPS, and the sub-apps you may also need to modify. Open this before editing chip fetcher."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3L3 6v15l6-3 6 3 6-3V3l-6 3-6-3z"/><path d="M9 3v15M15 6v15"/></svg></button>+ <button class="iconbtn" id="btn-reveal-lib" aria-label="Library folder" data-tooltip="Reveal the chip fetcher library folder in the VS Code Explorer sidebar. Opens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button>+ <button class="iconbtn" id="btn-settings" aria-label="Settings" data-tooltip="Settings: components and their install buttons, the taskbar progress mode, dashboard behaviour, and vendor logins."><!-- the shared Adom cog (assets/icons/settings-cog.svg), inlined so it follows currentColor --><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg></button>+ </div> <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">- <div class="popover-title">adom-chip-fetcher</div>- <div class="popover-row"><span class="k">about</span>- <span class="v">Download manufacturer-grade chip CAD bundles — STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet — into a structured library by driving Chrome via pup. Manufacturer-first sourcing ladder, then SnapEDA / Mouser / DigiKey / Ultra Librarian / Component Search Engine.</span></div>- <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.2</span></div>- <div class="popover-row"><span class="k">built</span><span class="v" id="popBuilt">2026-05-03</span></div>- <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+ <div class="popover-title">Chip Fetcher</div>+ <div class="popover-row"><span class="k">about</span><span class="v">Downloads manufacturer-grade chip CAD bundles (STEP 3D, Altium IntLib, Fusion 360 .lbr, KiCad symbol and footprint, datasheet) into a structured library by driving Chrome through pup. Manufacturer-first sourcing, then SnapEDA, Mouser, DigiKey, Ultra Librarian and Component Search Engine.</span></div>+ <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">?</span></div>+ <div class="popover-row"><span class="k">library</span><span class="v" id="popLibrary">?</span></div>+ <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc, adom.inc</span></div> <div class="popover-actions">- <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher" target="_blank" rel="noopener">Open wiki ↗</a>- <a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub ↗</a>+ <a class="popover-link" href="https://wiki.adom.inc/adom/adom-chip-fetcher" target="_blank" rel="noopener">Open wiki <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg></a>+ <a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/></svg></a> </div> </div> </header> -<div class="toolbar" role="toolbar" aria-label="Library filters">- <input type="search" id="q" placeholder="search MPN, vendor, package…">- <select id="vendor"><option value="">all vendors</option></select>- <select id="status">- <option value="">all status</option>- <option value="full">fully-bundled</option>- <option value="stub">stubs only</option>- <option value="no-info">missing info.json</option>- <option value="no-pdf">missing datasheet</option>+<div class="filterbar" role="toolbar" aria-label="Library filters">+ <input type="search" id="q" placeholder="Search part number, vendor or package" data-tooltip="Filter the cards by part number, vendor or package. Typing narrows the grid as you go.">+ <select id="vendor" data-tooltip="Show only one vendor's parts."><option value="">All vendors</option></select>+ <select id="status" data-tooltip="Show only full bundles, only stubs, or cards missing a specific file.">+ <option value="">All status</option>+ <option value="full">Fully bundled</option>+ <option value="stub">Stubs only</option>+ <option value="no-info">Missing info.json</option>+ <option value="no-pdf">Missing datasheet</option> </select>- <button id="btn-validate" class="spacer" data-tooltip="Full self-audit: a ✓/✗ matrix of EVERY required file for EVERY chip (datasheet, STEP, symbol, footprint, and the 3 card thumbnails). Goes green ONLY when truly complete — so 'done' can't be faked.">✓ Validation</button>- <button id="btn-sources" data-tooltip="The exact priority order chip-fetcher uses to search the entire internet for electronics data: manufacturer site first, then the Adom data tools (parts-search / Mouser / DigiKey / JLCPCB), then the manual CAD-crawl skills (SnapMagic / CSE / UltraLibrarian), with LCSC dead last.">⛓ Sourcing order</button>- <button id="btn-creds" data-tooltip="Manage saved credentials for vendor portals (Component Search Engine, etc.).\nUsed to authenticate downloads from SnapEDA / Ultra Librarian / DigiKey when a chip needs a login.">Credentials</button>- <button id="btn-reveal-lib" data-tooltip="Reveal the chip-fetcher library folder in the VS Code Explorer sidebar.\nOpens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg>Library folder</button>- <button id="btn-arch" data-tooltip="Architecture map — every tool chip-fetcher depends on: local CLIs (run in your container), native Rust libraries, remote service containers (called over HTTPS), and the sub-apps you may also need to modify. Open this before editing chip-fetcher to understand the whole structure.">🗺 Architecture</button>- <label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb"> auto-zoom</label>+ <div class="project-pills" id="project-tabs" role="tablist" aria-label="Projects" hidden></div> </div> -<div class="project-tabs" id="project-tabs" style="display:none;padding:0 16px 6px;gap:6px;align-items:center">- <span style="font-size:11px;color:var(--text2);margin-right:4px">project:</span>+<div class="app-body" id="appBody">+ <div class="content" id="content">+ <section class="say" id="say" aria-labelledby="sayTitle">+ <div class="say-head">+ <h2 id="sayTitle">Say it to your AI</h2>+ <span class="say-sub">You do not drive this dashboard by hand. You ask your AI, using the words <b>chip fetcher</b>. Click a phrase to copy it; Try it stages it in this app's AI thread.</span>+ </div>+ <div class="say-list" id="sayList"></div>+ </section>+ <div class="leds" id="leds" aria-label="Component status"></div>+ <section class="welcome" id="welcome" hidden>+ <h2>Welcome to Chip Fetcher</h2>+ <p>Your library is empty, so this page has nothing to show yet. Three steps get the first card on screen.</p>+ <ol>+ <li><span class="num">1</span><div>Check the component lights above and fix anything that is not green. The Components card in Settings has an Install button for each one.<br><button class="btn" id="btn-welcome-settings">Open Settings to Components</button></div></li>+ <li><span class="num">2</span><div>Ask your AI one of the phrases above, for example "fetch STM32F103C8T6 with chip fetcher". The AI drives pup on your desktop to find the manufacturer's files.</div></li>+ <li><span class="num">3</span><div>Cards appear here as parts are fetched. Each card carries the datasheet, the KiCad symbol, the footprint and the 3D model, with one-click open and send-to-EDA buttons.</div></li>+ </ol>+ </section>+ <div id="grid-view"><div class="cards" id="cards"><div class="empty-state">Loading the library</div></div></div>+ <div id="validation-view" class="view" hidden></div>+ <div id="sources-view" class="view" hidden></div>+ </div>+ <aside class="empty" id="detail">Select a card to see its files, details and actions.</aside> </div>--<div class="activity" id="activity-bar">- <span class="dot"></span>- <span class="label">working on</span>- <span class="mpn-tag" id="activity-mpn"></span>- <span class="stage" id="activity-stage"></span> </div> -<div class="modal-overlay" id="creds-modal">- <div class="modal">- <h3>Saved credentials</h3>- <div class="helper">- Stored at <code>~/.config/chip-fetcher/credentials.json</code> (chmod 600 — only readable by your container user). chip-fetcher rewrites outbound URLs to <code>https://user:pass@host/…</code> so vendor portals like Component Search Engine never show a basic-auth popup.- <br><br>- Passwords are masked by default — click the dots to reveal or hide. The vault is plaintext on disk (vendor-portal creds aren't bank logins) but the UI keeps them off-screen so a casual screenshot doesn't leak them.- </div>- <div class="creds-list" id="creds-list"><div class="info">loading…</div></div>-- <h3 style="margin-top:24px">Add / update</h3>- <label>Host pattern (glob)</label>- <input id="cred-host" placeholder="*.componentsearchengine.com" value="*.componentsearchengine.com">- <label>Username</label>- <input id="cred-user" placeholder="[email protected]">- <label>Password</label>- <input id="cred-pass" type="password" placeholder="password">-- <div class="buttons">- <button class="btn-secondary" id="cred-close">Close</button>- <button class="btn-primary" id="cred-save">Save credentials</button>- </div>- <div id="cred-msg"></div>+<dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">+ <div class="settings-head" id="settingsHead">+ <h2 id="settingsTitle">Chip Fetcher Settings <span class="card-status" id="settingsVersion"></span></h2>+ <button class="iconbtn" id="btnCloseSettings" aria-label="Close settings" data-tooltip="Close settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button> </div>-</div>-<main>- <div class="grid" id="grid-view">- <div class="cards" id="cards"><div class="empty-state">loading library…</div></div>+ <div class="settings-body" id="settingsBody">++ <section class="settings-card" id="card-components">+ <h3 class="card-title">Components <span class="card-status" id="stComponents"></span></h3>+ <p class="card-desc-s">Chip fetcher is five pieces working together, and each light below is recomputed from the box every fifteen seconds. The <b>Dashboard</b> is this page and its server. The <b>AI skill</b> is the file at <code>~/.claude/skills/adom-chip-fetcher/SKILL.md</code> that teaches your AI how to drive the app. The <b>Sister tools</b> are thirteen command-line programs that chip fetcher calls: adom-symbol, adom-footprint, adom-step, adom-lbr, adom-chipsmith, adom-chip-thumbnailer, adom-concur, adom-ds2sf, adom-sfconvert, adom-parts-search, adom-mouser, adom-digikey and adom-jlcpcb. The <b>Desktop</b> is a connected Adom Bridge box where pup runs Chrome to fetch files. The <b>Library</b> is the folder of fetched parts behind the cards.</p>+ <p class="card-desc-s">Install runs the real command in the background and streams its output into a console under the row, line by line, ending with the exit code. Sister tools install with <code>adom-wiki pkg install adom/adom-chip-fetcher</code>; the skill installs with <code>adom-chip-fetcher install</code>. Nothing here is silent.</p>+ <div id="compRows"></div>+ </section>++ <section class="settings-card" id="card-taskbar">+ <h3 class="card-title">Taskbar <span class="card-status" id="stTaskbar"></span></h3>+ <p class="card-desc-s">While your AI runs a part lookup, chip fetcher paints an indeterminate progress bar on the dashboard's Windows taskbar button, so you can see it is working without a false estimate. The bar is driven through the Windows taskbar API on the connected desktop and clears itself the moment the lookup ends.</p>+ <div class="wrapper">+ <h4 class="name" title="taskbar_progress"><span class="prefix">Taskbar: </span><span class="title">Progress during a lookup</span></h4>+ <fieldset class="choice-set" role="radiogroup" aria-labelledby="taskbarLabel">+ <legend id="taskbarLabel" class="set-check-label" style="padding:0;margin-bottom:.35rem">What the dashboard's taskbar button does while a lookup is running.</legend>+ <label class="choice" data-mode="indeterminate" data-selected="false">+ <input type="radio" name="taskbar_progress" value="indeterminate">+ <span class="choice-shot">screenshot pending</span>+ <span class="choice-name">Indeterminate bar</span>+ <span class="choice-desc">A moving marquee under the icon for as long as the lookup runs. Says "working", never a made-up percentage.</span>+ </label>+ <label class="choice" data-mode="off" data-selected="false">+ <input type="radio" name="taskbar_progress" value="off">+ <span class="choice-shot">screenshot pending</span>+ <span class="choice-name">Off</span>+ <span class="choice-desc">The taskbar button never changes. You rely on the header pill and the card ring on this page.</span>+ </label>+ </fieldset>+ <span class="default-hint" id="dhTaskbar">Default: Indeterminate bar</span>+ </div>+ </section>++ <section class="settings-card" id="card-dashboard">+ <h3 class="card-title">Dashboard <span class="card-status" id="stDashboard"></span></h3>+ <p class="card-desc-s">How this page behaves while your AI works. Every switch applies the instant you flip it and is written to the settings file on this container; the page you are looking at is the preview.</p>+ <div class="wrapper">+ <h4 class="name" title="auto_select_active"><span class="prefix">Dashboard: </span><span class="title">Follow the active lookup</span></h4>+ <p class="row-desc">When a heartbeat names a new part, select that card and scroll it into view, so the detail panel always shows the part being fetched. Turn it off if you are inspecting one card while a long batch runs.</p>+ <div class="setting-row"><button class="toggle" id="tglAutoSelect" role="switch" aria-checked="true" aria-label="Follow the active lookup"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Select and scroll to the card being fetched</span></div>+ <span class="default-hint" id="dhAutoSelect">Default: On</span>+ </div>+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="auto_zoom"><span class="prefix">Dashboard: </span><span class="title">Auto-zoom the active card</span></h4>+ <p class="row-desc">Zoom the active card into the centred overlay whenever the AI moves to a new part, so you can watch progress at a readable size without clicking each card. Press Escape or the X to drop back to the grid at any time.</p>+ <div class="setting-row"><button class="toggle" id="tglAutoZoom" role="switch" aria-checked="false" aria-label="Auto-zoom the active card"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Zoom the card being fetched</span></div>+ <span class="default-hint" id="dhAutoZoom">Default: Off</span>+ </div>+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="show_stock_badges"><span class="prefix">Dashboard: </span><span class="title">Stock and lifecycle badges on cards</span></h4>+ <p class="row-desc">Show a Mouser stock pill, and an NRND or end-of-life warning, on each card that has distributor data. With it off the cards stay lean and the same numbers remain in the detail panel's Status tab.</p>+ <div class="setting-row"><button class="toggle" id="tglStock" role="switch" aria-checked="true" aria-label="Stock and lifecycle badges on cards"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="set-check-label">Show stock and lifecycle pills on cards</span></div>+ <span class="default-hint" id="dhStock">Default: On</span>+ </div>+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="activity_stale_seconds"><span class="prefix">Dashboard: </span><span class="title">Heartbeat considered stale after</span></h4>+ <p class="row-desc">Fetch jobs post heartbeats with no terminal state, so the page stops showing "Fetching" once the last heartbeat is older than this many seconds. Raise it for slow vendor sites, lower it if a finished lookup lingers.</p>+ <div class="setting-row"><input class="set-input num" id="inpStale" type="number" min="10" max="3600" step="5" aria-label="Heartbeat considered stale after, in seconds"><span class="set-check-label">seconds</span></div>+ <span class="default-hint" id="dhStale">Default: 90</span>+ </div>+ </section>++ <section class="settings-card" id="card-logins">+ <h3 class="card-title">Logins <span class="card-status" id="stLogins"></span></h3>+ <p class="card-desc-s">Some vendor portals (Component Search Engine, Ultra Librarian, SnapEDA, TI, ST, Microchip) only hand out CAD files to a signed-in browser. pup keeps those logins in the desktop's OS keychain and fills them in the background when a lookup lands on a login page; chip fetcher stores none of them and never sees a password. This list shows host and username only.</p>+ <div id="credList" class="cred-list"></div>+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="import_logins"><span class="prefix">Logins: </span><span class="title">Import my browser logins</span></h4>+ <p class="row-desc">Copies the saved passwords from your desktop's Chrome or Edge into pup's vault. Expect one consent dialog and one UAC prompt on the desktop; the output streams below.</p>+ <div class="setting-row"><button class="btn" id="btnImportLogins">Import my browser logins</button></div>+ <div class="console" id="conImport"></div>+ </div>+ <div class="divider"></div>+ <div class="wrapper">+ <h4 class="name" title="login"><span class="prefix">Logins: </span><span class="title">Sign in to a vendor</span></h4>+ <p class="row-desc">Opens that vendor's sign-in page in the chip fetcher pup profile on your desktop and asks pup to fill the saved login. If there is none yet, sign in there once and pup captures it.</p>+ <div class="setting-row">+ <select class="set-input" id="selLogin" aria-label="Vendor to sign in to">+ <option value="google">Google</option><option value="ti">TI</option><option value="st">ST</option><option value="microchip">Microchip</option><option value="ultralib">Ultra Librarian</option><option value="snapeda">SnapEDA</option><option value="componentsearch">Component Search Engine</option>+ </select>+ <button class="btn" id="btnLogin">Sign in</button>+ </div>+ </div>+ </section>++ <footer class="settings-footer">+ <p class="footer-note">Settings are stored in <code>~/.config/adom-chip-fetcher</code> on this container. Restoring returns every option above to its default.</p>+ <button class="restore-btn" id="btnRestore" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><path id="restoreIcon" fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z"/></svg><span id="restoreLabel">Restore defaults</span></button>+ </footer> </div>- <div id="validation-view" style="display:none;grid-column:1/-1;padding:18px 20px;overflow:auto"></div>- <div id="sources-view" style="display:none;grid-column:1/-1;padding:18px 24px;overflow:auto"></div>- <aside class="empty" id="detail">select a card to see details</aside>-</main>+</dialog>++<dialog id="bridgeModal" aria-labelledby="bmTitle"><div class="bm-card" id="bmCard"></div></dialog>+<div id="toasts" aria-live="polite"></div>+<div id="tt" role="tooltip"></div>+<div id="infosheet" role="dialog" aria-label="Explanation"><div id="infosheetText"></div><button class="btn" id="infosheetClose">Close</button></div> <script>-let LIB = null;-let SELECTED = null;-let DETAIL_TAB = 'actions'; // which detail-panel tab is open (Actions | Details | Files)-// Per-chip+kind source the user picked in the provenance carat (e.g. "ultralibrarian").-// Keyed "mpn|kind". Persisted so the choice sticks across reloads.-let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch { return {}; } })();-function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch {} }-// The source method to treat as active for a tile: the user's pick if it's still-// a valid source, else the backend canonical ("current").+'use strict';+const $ = s => document.querySelector(s);+const $id = id => document.getElementById(id);+function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]); }+// Relative paths so the page works behind /proxy/<port>/ and inside a webview.+const API_BASE = location.pathname.replace(/index\.html$/, '').replace(/\/$/, '');+const DASH = '\u2014'; // the data sentinel inventory.rs emits for "unknown"; never shown to a person+const isPhone = () => matchMedia('(hover: none)').matches || window.innerWidth < 720;++// ---- Monochrome 24-viewBox stroke icons (currentColor). No emoji, ever. ----+const ICONS = {+ check: '<polyline points="20 6 9 17 4 12"/>',+ x: '<path d="M18 6L6 18M6 6l12 12"/>',+ warn: '<path d="M12 9v4M12 17h.01M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/>',+ refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/>',+ image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',+ download: '<path d="M12 3v12M7 10l5 5 5-5M4 21h16"/>',+ folder: '<path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/>',+ zoom: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/>',+ chevron: '<path d="M6 9l6 6 6-6"/>',+ cube: '<path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 12l9-5M12 12v10M12 12L3 7"/>',+ doc: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/>',+ pkg: '<path d="M21 8l-9-5-9 5v8l9 5 9-5z"/><path d="M3 8l9 5 9-5M12 13v8"/>',+ ext: '<path d="M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6"/>',+ trash: '<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/>',+ bridge: '<path d="M3 18v-6a9 9 0 0 1 18 0v6M3 14h18M7 18v-4M12 18v-9M17 18v-4"/>',+ pin: '<circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>',+ axis: '<path d="M12 20V6M8 10l4-4 4 4M4 20h16"/>',+};+function icon(name, size) { return `<svg viewBox="0 0 24 24" width="${size||16}" height="${size||16}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`; }++// ---- Console relay: every console line and error also lands in GET /console (20/s cap, never recursive). ----+(function () {+ const orig = { log: console.log, warn: console.warn, error: console.error };+ const nativeFetch = window.fetch.bind(window);+ let bucket = 0, bucketAt = Date.now();+ const fmt = a => { if (a instanceof Error) return a.message + (a.stack ? ' ' + a.stack.split('\n')[1] : ''); if (typeof a === 'object') { try { return JSON.stringify(a).slice(0, 600); } catch (e) { return String(a); } } return String(a); };+ function post(level, msg) {+ const now = Date.now();+ if (now - bucketAt > 1000) { bucket = 0; bucketAt = now; }+ if (++bucket > 20) return;+ try { nativeFetch(API_BASE + '/console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level, msg: String(msg).slice(0, 2000) }), keepalive: true }).catch(() => {}); } catch (e) {}+ }+ ['log', 'warn', 'error'].forEach(k => { console[k] = function (...a) { try { orig[k].apply(console, a); } catch (e) {} post(k, a.map(fmt).join(' ')); }; });+ window.addEventListener('error', e => post('error', 'onerror: ' + (e.message || e) + ' @' + (e.filename || '').split('/').pop() + ':' + (e.lineno || '')));+ window.addEventListener('unhandledrejection', e => post('error', 'unhandledrejection: ' + fmt(e.reason)));+})();++// ---- Toasts (toasts skill): cfToast(msg, {type:'ok'|'err'|'info', action:{label,fn}, ttl, onExpire}) ----+const TOAST_CAP = 4;+function cfToast(msg, opts) {+ opts = opts || {};+ const type = opts.type === 'success' ? 'ok' : (opts.type === 'error' ? 'err' : (opts.type || 'info'));+ const host = $id('toasts');+ while (host.children.length >= TOAST_CAP) host.firstElementChild.remove();+ const t = document.createElement('div');+ t.className = 'toast ' + type;+ t.setAttribute('role', type === 'err' ? 'alert' : 'status');+ t.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');+ t.innerHTML = `<span class="ticon">${icon(type === 'ok' ? 'check' : type === 'err' ? 'warn' : 'info')}</span><span class="tmsg"></span>`;+ t.querySelector('.tmsg').textContent = msg;+ if (opts.href) { const a = document.createElement('a'); a.className = 'taction'; a.href = opts.href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = opts.hrefLabel || 'Open'; t.appendChild(a); }+ let expired = false, undone = false;+ const done = () => { if (expired) return; expired = true; t.remove(); if (!undone && opts.onExpire) opts.onExpire(); };+ if (opts.action) { const b = document.createElement('button'); b.className = 'taction'; b.textContent = opts.action.label; b.onclick = () => { undone = true; expired = true; t.remove(); opts.action.fn(); }; t.appendChild(b); }+ const x = document.createElement('button'); x.className = 'tx'; x.setAttribute('aria-label', 'Dismiss'); x.innerHTML = icon('x'); x.onclick = done; t.appendChild(x);+ const ttl = opts.ttl != null ? opts.ttl : (type === 'err' ? 0 : (opts.action ? 6000 : 4000));+ let timer = null, left = ttl, started = 0;+ const arm = () => { if (!ttl) return; started = Date.now(); timer = setTimeout(done, left); };+ t.addEventListener('mouseenter', () => { if (timer) { clearTimeout(timer); timer = null; left -= Date.now() - started; } });+ t.addEventListener('mouseleave', () => { if (ttl && !timer && !expired) arm(); });+ host.appendChild(t);+ arm();+ return t;+}++// ---- Tooltips (tooltips skill): body-level fixed div, 600 ms reveal, cursor-relative, clamped on both axes. ----+const tip = $id('tt');+let _cx = 0, _cy = 0, tipTimer = null, tipEl = null;+document.addEventListener('mousemove', e => { _cx = e.clientX; _cy = e.clientY; }, true);+function pickAnchor(trigger, tipW, tipH, cx, cy) {+ const O = 14, M = 8, vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;+ const tr = trigger.getBoundingClientRect();+ const cands = [{ x: cx + O, y: cy + O }, { x: cx - tipW - O, y: cy + O }, { x: cx + O, y: cy - tipH - O }, { x: cx - tipW - O, y: cy - tipH - O }];+ const score = c => {+ let p = 0;+ if (c.x < M || c.x + tipW > vw - M) p += 1000;+ if (c.y < M || c.y + tipH > vh - M) p += 1000;+ const ox = Math.max(0, Math.min(c.x + tipW, tr.right) - Math.max(c.x, tr.left));+ const oy = Math.max(0, Math.min(c.y + tipH, tr.bottom) - Math.max(c.y, tr.top));+ return p + ox * oy;+ };+ const best = cands.map(c => ({ c, s: score(c) })).sort((a, b) => a.s - b.s)[0].c;+ best.x = Math.max(M, Math.min(best.x, vw - tipW - M));+ best.y = Math.max(M, Math.min(best.y, vh - tipH - M));+ return best;+}+function showTip(trigger) {+ if (isPhone()) return;+ tip.textContent = trigger.getAttribute('data-tooltip') || '';+ if (!tip.textContent) return;+ tip.classList.add('visible');+ const { x, y } = pickAnchor(trigger, tip.offsetWidth, tip.offsetHeight, _cx, _cy);+ tip.style.setProperty('--tip-x', x + 'px'); tip.style.setProperty('--tip-y', y + 'px');+ requestAnimationFrame(() => tip.classList.add('shown'));+}+function hideTip() { clearTimeout(tipTimer); tipTimer = null; tipEl = null; tip.classList.remove('visible', 'shown'); }+document.addEventListener('mouseover', e => {+ const t = e.target.closest('[data-tooltip]');+ if (!t) { hideTip(); return; }+ if (t === tipEl) return;+ hideTip(); tipEl = t;+ tipTimer = setTimeout(() => showTip(t), 600);+});+document.addEventListener('mousedown', hideTip, true);+document.addEventListener('click', hideTip, true);+window.addEventListener('scroll', hideTip, { passive: true, capture: true });+// Touch: a tap on a non-interactive carrier (badge, pill, label) or a 450 ms press on anything opens the info sheet.+(function () {+ const sheet = $id('infosheet'), txt = $id('infosheetText');+ let pressT = null, moved = false;+ const open = t => { txt.textContent = t.getAttribute('data-tooltip') || ''; sheet.classList.add('open'); };+ $id('infosheetClose').onclick = () => sheet.classList.remove('open');+ document.addEventListener('touchstart', e => {+ const t = e.target.closest('[data-tooltip]'); moved = false; clearTimeout(pressT);+ if (!t) return;+ pressT = setTimeout(() => { if (!moved) { open(t); if (navigator.vibrate) navigator.vibrate(8); } }, 450);+ }, { passive: true });+ document.addEventListener('touchmove', () => { moved = true; clearTimeout(pressT); }, { passive: true });+ document.addEventListener('touchend', () => clearTimeout(pressT), { passive: true });+ document.addEventListener('click', e => {+ if (!isPhone()) return;+ const t = e.target.closest('[data-tooltip]');+ if (t && !t.closest('button, a, select, input, .thumb, .choice, .toggle')) { open(t); e.stopPropagation(); }+ else if (!sheet.contains(e.target)) sheet.classList.remove('open');+ }, true);+})();++// ---- One floating menu for the whole app (refetch, orientation, source picker), clamped to the viewport. ----+let FLOAT = null;+function closeMenu() { if (FLOAT) { FLOAT.remove(); FLOAT = null; } }+function openMenu(anchor, html, opts) {+ closeMenu();+ const el = document.createElement('div'); el.className = 'floating'; el.innerHTML = html;+ if (opts && opts.width) el.style.width = opts.width + 'px';+ document.body.appendChild(el); FLOAT = el;+ const r = anchor.getBoundingClientRect(), M = 8, vw = innerWidth, vh = innerHeight;+ const w = el.offsetWidth, h = el.offsetHeight;+ let x = r.left, y = r.bottom + 6;+ if (y + h > vh - M) y = Math.max(M, r.top - h - 6);+ x = Math.max(M, Math.min(x, vw - w - M)); y = Math.max(M, Math.min(y, vh - h - M));+ el.style.left = x + 'px'; el.style.top = y + 'px';+ el.addEventListener('click', e => e.stopPropagation());+ return el;+}+document.addEventListener('click', e => { if (FLOAT && !FLOAT.contains(e.target)) closeMenu(); });+document.addEventListener('keydown', e => { if (e.key === 'Escape' && FLOAT) closeMenu(); });+window.addEventListener('resize', closeMenu);++// ---- Header popover: 600 ms show, 220 ms hide grace, reachable with the pointer. ----+(function () {+ const trigger = $id('brand'), target = $id('app-info');+ let showTimer = null, hideTimer = null;+ const show = () => { clearTimeout(hideTimer); if (target.classList.contains('show')) return; clearTimeout(showTimer); showTimer = setTimeout(() => target.classList.add('show'), 600); };+ const queueHide = () => { clearTimeout(showTimer); clearTimeout(hideTimer); hideTimer = setTimeout(() => target.classList.remove('show'), 220); };+ trigger.addEventListener('mouseenter', show); trigger.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('focus', show); trigger.addEventListener('blur', queueHide);+ target.addEventListener('mouseenter', show); target.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('click', e => { clearTimeout(showTimer); target.classList.toggle('show'); e.stopPropagation(); });+ document.addEventListener('click', e => { if (!target.contains(e.target) && !trigger.contains(e.target)) target.classList.remove('show'); });+})();++// ---- State. The server copy (GET /state) is the truth for the AI; the page reports its own view through POST /state/ui. ----+let LIB = null, SELECTED = null, DETAIL_TAB = 'actions';+let ACTIVE_MPN = null, ACTIVE_STAGE = '', PROJECTS = null, ACTIVE_PROJECT_FILTER = null;+let VIEW = 'grid', ZOOM_MPN = null, STATE_VERSION = null, STATUS = null, SERVER_VERSION = '';+const SETTINGS_DEFAULTS = { taskbar_progress: 'indeterminate', auto_zoom: false, auto_select_active: true, show_stock_badges: true, activity_stale_seconds: 90 };+let SETTINGS = Object.assign({}, SETTINGS_DEFAULTS), SETTINGS_META = { defaults: SETTINGS_DEFAULTS, labels: {}, enums: {} };+// Per-chip+kind source the user picked in the provenance chip (e.g. "ultralibrarian"). Keyed "mpn|kind".+let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch (e) { return {}; } })();+function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch (e) {} } function activeMethod(mpn, kind, sources) {- const key = mpn + '|' + kind;- const pick = SRC_SEL[key];+ const pick = SRC_SEL[mpn + '|' + kind]; if (pick && sources.some(s => s.method === pick)) return pick; const cur = sources.find(s => s.current); return cur ? cur.method : (sources[0] && sources[0].method); }-let ACTIVE_MPN = null;-let ACTIVE_STAGE = '';-let LAST_LIB_HASH = '';-let PROJECTS = null;-let ACTIVE_PROJECT_FILTER = null; // null = show all, string = filter to project+function chipByMpn(mpn) { return ((LIB && LIB.chips) || []).find(x => x.mpn === mpn); }+async function api(path, opts) { const r = await fetch(API_BASE + path, opts); return r.json(); }+function postJson(path, body, extra) { return fetch(API_BASE + path, Object.assign({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, extra || {})); } -// --- Interactive hover-popover (600 ms show-delay, 220 ms hide-grace) ----// per adom-app-header §Reference JS + human-ui-patterns §1b (600 ms).-// Hover-intent prevents fly-by triggers; the hide-grace lets the user-// move from the brand block into the popover to click the wiki link.-(function(){- const SHOW_DELAY = 600; // human-ui-patterns §1b- const HIDE_GRACE = 220;- document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {- const target = document.getElementById(trigger.dataset.tooltipTarget);- if (!target) return;- let showTimer = null, hideTimer = null;- const show = () => {- clearTimeout(hideTimer);- if (target.classList.contains('show')) return;- clearTimeout(showTimer);- showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);- };- const queueHide = () => {- clearTimeout(showTimer);- clearTimeout(hideTimer);- hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);- };- trigger.addEventListener('mouseenter', show);- trigger.addEventListener('mouseleave', queueHide);- trigger.addEventListener('focus', show);- trigger.addEventListener('blur', queueHide);- target.addEventListener('mouseenter', show);- target.addEventListener('mouseleave', queueHide);- trigger.addEventListener('click', e => {- clearTimeout(showTimer);- target.classList.toggle('show');- e.stopPropagation();- });- document.addEventListener('click', e => {- if (!target.contains(e.target) && !trigger.contains(e.target)) target.classList.remove('show');- });- });-})();+let reportT = null;+function reportUi() {+ clearTimeout(reportT);+ reportT = setTimeout(() => { postJson('/state/ui', { selected_mpn: SELECTED, view: VIEW, settings_open: $id('dlgSettings').open, zoom_mpn: ZOOM_MPN }).catch(() => {}); }, 80);+} -// Update the header subject zone — currently-fetched/selected MPN + state-pill-// + library-level meta counts. Called from pollActivity, load, and select so-// the header reflects "what we're doing right now" continuously.+function agoLabel(s) { if (s == null) return ''; if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; return Math.floor(s / 3600) + 'h ago'; }++// Header subject zone: the part being fetched or selected, its state pill, and library counts. function updateSubject() {- const nameEl = document.getElementById('subjectName');- const pillEl = document.getElementById('subjectPill');+ const nameEl = $id('subjectName'), pillEl = $id('subjectPill'); const focusMpn = ACTIVE_MPN || SELECTED || null; if (focusMpn) {- nameEl.textContent = focusMpn;- nameEl.classList.remove('idle');- pillEl.classList.add('show');+ nameEl.textContent = focusMpn; nameEl.classList.remove('idle'); pillEl.classList.add('show'); if (ACTIVE_MPN === focusMpn) {- pillEl.textContent = 'FETCHING';- pillEl.className = 'state-pill show fetching';- pillEl.setAttribute('data-tooltip', ACTIVE_STAGE || 'fetching…');+ pillEl.textContent = 'Fetching'; pillEl.className = 'state-pill show fetching';+ pillEl.setAttribute('data-tooltip', 'Your AI is fetching this part right now. Stage: ' + (ACTIVE_STAGE || 'starting')); } else {- const chip = LIB && LIB.chips.find(c => c.mpn === focusMpn);- if (chip && chip.fully_bundled) {- pillEl.textContent = 'FULL';- pillEl.className = 'state-pill show full';- pillEl.setAttribute('data-tooltip', 'Fully bundled — STEP 3D + schematic symbol + PCB footprint + datasheet are all present.');- } else {- pillEl.textContent = 'STUB';- pillEl.className = 'state-pill show stub';- pillEl.setAttribute('data-tooltip', chip ? 'Missing one or more files (STEP / symbol / footprint / datasheet). Use the ⤓ menu to fetch the rest.' : 'unknown');- }+ const chip = chipByMpn(focusMpn);+ if (chip && chip.fully_bundled) { pillEl.textContent = 'Full'; pillEl.className = 'state-pill show full'; pillEl.setAttribute('data-tooltip', 'Fully bundled: STEP 3D, schematic symbol, PCB footprint and datasheet are all present.'); }+ else { pillEl.textContent = 'Stub'; pillEl.className = 'state-pill show stub'; pillEl.setAttribute('data-tooltip', chip ? 'Missing one or more files (STEP, symbol, footprint or datasheet). Use the re-fetch button on the card to fetch the rest.' : 'Unknown part'); } } } else {- nameEl.textContent = '—';- nameEl.classList.add('idle');- pillEl.classList.remove('show');- pillEl.removeAttribute('title');+ nameEl.textContent = 'No chip selected'; nameEl.classList.add('idle'); pillEl.classList.remove('show'); } if (LIB) {- document.getElementById('metaCards').textContent = `${LIB.fully_count + LIB.stub_count} cards`;- document.getElementById('metaFull').textContent = `${LIB.fully_count} full`;- document.getElementById('metaStubs').textContent = `${LIB.stub_count} stubs`;- document.getElementById('metaSize').textContent = `${(LIB.total_size/1024/1024).toFixed(0)} MB`;+ $id('metaCards').textContent = (LIB.fully_count + LIB.stub_count) + ' cards';+ $id('metaFull').textContent = LIB.fully_count + ' full';+ $id('metaStubs').textContent = LIB.stub_count + ' stubs';+ $id('metaSize').textContent = (LIB.total_size / 1024 / 1024).toFixed(0) + ' MB';+ $id('popLibrary').textContent = LIB.chips.length + ' parts, ' + LIB.fully_count + ' full'; } } -// Use relative paths so this works behind /proxy/<port>/ tunnels too.-const API_BASE = (() => {- const p = location.pathname;- // strip trailing index.html and trailing slash- return p.replace(/index\.html$/, '').replace(/\/$/, '');-})();--// A heartbeat older than this many seconds is treated as "not actively-// working" — heartbeats are write-only stage labels with no terminal state,-// so without an age check the last one ("fetching X") sticks forever.-const ACTIVITY_STALE_SECS = 25;--function agoLabel(s) {- if (s == null) return '';- if (s < 60) return s + 's ago';- if (s < 3600) return Math.floor(s / 60) + 'm ago';- return Math.floor(s / 3600) + 'h ago';-}--// Re-fetch the library and re-render iff the meaningful per-chip state-// changed. Returns true if it re-rendered. Called from pollActivity AND a-// steady timer, so card bodies (files, thumbnails, validation) stay fresh-// even when nothing is actively fetching — previously the library was only-// re-polled while a heartbeat was live, so idle cards went stale until reload.-async function refreshLibraryIfChanged() {- try {- const libR = await fetch(API_BASE + '/api/library');- const newLib = await libR.json();- const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.sym_rev, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status, c.pin1_signed, c.pin1_corner, c.fp_pin1_corner]));- if (hash !== LAST_LIB_HASH) {- LIB = newLib;- LAST_LIB_HASH = hash;- render();- updateSubject();- renderProjectTabs();- if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }- return true;- }- } catch (e) { /* transient */ }- return false;-}--// Poll /api/activity every 1s. Drives the banner + the WORKING card highlight.-async function pollActivity() {- try {- const r = await fetch(API_BASE + '/api/activity');- const a = await r.json();- const mpn = a && a.mpn ? a.mpn : null;- const stage = a && a.stage ? a.stage : '';- const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;- const bar = document.getElementById('activity-bar');- const mpnEl = document.getElementById('activity-mpn');- const stEl = document.getElementById('activity-stage');- const terminalStage = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';- const stale = age != null && age > ACTIVITY_STALE_SECS;- const working = mpn && !terminalStage && !stale;-- if (working) {- bar.classList.add('live');- bar.classList.remove('stale');- mpnEl.textContent = mpn;- stEl.textContent = stage ? '— ' + stage : '';- const stageChanged = stage !== ACTIVE_STAGE;- ACTIVE_STAGE = stage;- if (mpn !== ACTIVE_MPN) {- ACTIVE_MPN = mpn;- render();- updateSubject();- scrollActiveCardIntoView();- autoZoomIfEnabled(mpn);- } else if (stageChanged) {- updateSubject();- }- await refreshLibraryIfChanged();- } else {- // Clear the WORKING highlight the moment work stops/goes stale.- if (ACTIVE_MPN !== null) {- ACTIVE_MPN = null;- ACTIVE_STAGE = '';- render();- updateSubject();- }- // Don't lie with a permanent green WORKING bar. If there's a stale- // last-heartbeat, show it muted with its age; otherwise hide the bar.- if (mpn && stale && stage) {- bar.classList.remove('live');- bar.classList.add('stale');- mpnEl.textContent = mpn;- stEl.textContent = '— idle · last: ' + stage + (age != null ? ' · ' + agoLabel(age) : '');- } else {- bar.classList.remove('live', 'stale');- }- }- } catch (e) { /* ignore transient failures */ }-}-setInterval(pollActivity, 1000);-// Keep card bodies live even when idle (no active heartbeat).-setInterval(refreshLibraryIfChanged, 4000);--async function load() {- const r = await fetch(API_BASE + '/api/library');- LIB = await r.json();- updateSubject();- const vendors = [...new Set(LIB.chips.map(c => c.vendor))].sort();- const vsel = document.getElementById('vendor');- for (const v of vendors) {- const o = document.createElement('option'); o.value = v; o.textContent = v;- vsel.appendChild(o);- }- render();- loadProjects();+async function loadLibrary() {+ const newLib = await api('/api/library');+ const firstLoad = !LIB;+ LIB = newLib;+ const vsel = $id('vendor'), keep = vsel.value;+ const vendors = [...new Set(LIB.chips.map(c => c.vendor).filter(v => v && v !== '?' && v !== DASH))].sort();+ vsel.innerHTML = '<option value="">All vendors</option>' + vendors.map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');+ vsel.value = keep;+ $id('welcome').hidden = LIB.chips.length > 0;+ updateSubject(); render(); renderProjectTabs();+ if (firstLoad) loadProjects();+ if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }+ if (SELECTED && chipByMpn(SELECTED)) select(SELECTED, true); } async function loadProjects() {@@ -873,1596 +935,912 @@ async function loadProjects() { const r = await fetch(API_BASE + '/api/projects'); if (!r.ok) return; PROJECTS = await r.json();- // On first load, honor the server's active project so the dashboard opens- // already filtered (set via `chip-fetcher project activate <name>`), instead- // of dumping the full unfiltered library. The user can still tap "All". if (!window._projInit) { window._projInit = true;- if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) {- ACTIVE_PROJECT_FILTER = PROJECTS.active;- }+ if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) ACTIVE_PROJECT_FILTER = PROJECTS.active; }- renderProjectTabs();- applyDeepLink();- } catch {}+ renderProjectTabs(); render(); applyDeepLink();+ } catch (e) {} } -// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1 open the-// dashboard already filtered to the project, the chip selected + scrolled into-// view (with an optional source preselected), and optionally zoomed — so a-// shared link lands exactly on what to look at.+// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1 function applyDeepLink() { if (window._deepLinkDone || !LIB) return; window._deepLinkDone = true; const p = new URLSearchParams(location.search);- const proj = p.get('project'), chip = p.get('chip'), source = p.get('source');- const zoom = p.get('zoom'); const wantZoom = zoom && zoom !== '0' && zoom !== 'false';+ const proj = p.get('project'), chip = p.get('chip'), source = p.get('source'), zoom = p.get('zoom');+ const wantZoom = zoom && zoom !== '0' && zoom !== 'false'; if (!proj && !chip) return;- if (proj && PROJECTS && PROJECTS.projects && PROJECTS.projects[proj]) {- ACTIVE_PROJECT_FILTER = proj; renderProjectTabs();- }- if (source && chip) {- SRC_SEL[chip + '|symbol'] = source; SRC_SEL[chip + '|footprint'] = source; saveSrcSel();- }+ if (proj && PROJECTS && PROJECTS.projects && PROJECTS.projects[proj]) { ACTIVE_PROJECT_FILTER = proj; renderProjectTabs(); }+ if (source && chip) { SRC_SEL[chip + '|symbol'] = source; SRC_SEL[chip + '|footprint'] = source; saveSrcSel(); } render();- if (chip) {- setTimeout(() => {- select(chip);- const card = document.querySelector(`.card[data-mpn="${CSS.escape(chip)}"]`);- if (card) {- card.scrollIntoView({ behavior: 'smooth', block: 'center' });- if (wantZoom) setTimeout(() => zoomCard(card), 380);- }- }, 160);- }+ if (chip) setTimeout(() => {+ select(chip);+ const card = document.querySelector(`.card[data-mpn="${CSS.escape(chip)}"]`);+ if (card) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (wantZoom) setTimeout(() => zoomCard(card), 380); }+ }, 160); } function renderProjectTabs() {- const bar = document.getElementById('project-tabs');- if (!PROJECTS || !PROJECTS.projects || Object.keys(PROJECTS.projects).length === 0) {- bar.style.display = 'none';- return;- }- bar.style.display = 'flex';- const label = bar.querySelector('span');- bar.innerHTML = '';- bar.appendChild(label);-- // "All" tab- const allTab = document.createElement('div');- allTab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === null ? ' active' : '');- allTab.innerHTML = `Library<span class="count">${LIB ? LIB.chips.length : 0}</span>`;- allTab.onclick = () => { ACTIVE_PROJECT_FILTER = null; renderProjectTabs(); render(); };- bar.appendChild(allTab);-- // Per-project tabs- for (const [name, proj] of Object.entries(PROJECTS.projects)) {- const tab = document.createElement('div');- tab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === name ? ' active' : '');- tab.innerHTML = `${name}<span class="count">${proj.mpns.length}</span>`;- tab.onclick = () => { ACTIVE_PROJECT_FILTER = name; renderProjectTabs(); render(); };- bar.appendChild(tab);- }-- // NOTE: We deliberately do NOT auto-apply the server's persisted "active"- // project here. That field survives across sessions, so auto-narrowing the- // grid to it silently hid freshly-fetched chips behind a stale filter from- // weeks-old, unrelated work. The dashboard always opens on the full Library;- // the user clicks a project tab to filter. (PROJECTS.active is still shown- // highlighted if you want to know what the CLI considers active.)+ const bar = $id('project-tabs');+ if (!PROJECTS || !PROJECTS.projects || !Object.keys(PROJECTS.projects).length) { bar.hidden = true; return; }+ bar.hidden = false; bar.innerHTML = '';+ const mk = (label, n, key, tipText) => {+ const b = document.createElement('button'); b.className = 'tpill' + (ACTIVE_PROJECT_FILTER === key ? ' active' : '');+ b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', String(ACTIVE_PROJECT_FILTER === key)); b.setAttribute('data-tooltip', tipText);+ b.innerHTML = `${escapeHtml(label)}<span class="n">${n}</span>`;+ b.onclick = () => { ACTIVE_PROJECT_FILTER = key; renderProjectTabs(); render(); };+ bar.appendChild(b);+ };+ mk('Library', LIB ? LIB.chips.length : 0, null, 'Show every part in the library.');+ for (const [name, proj] of Object.entries(PROJECTS.projects)) mk(name, proj.mpns.length, name, 'Show only the parts in the ' + name + ' project' + (proj.description ? ': ' + proj.description : '.')); } -function srcTier(src) {- if (src.startsWith('mfr-direct')) return 'mfr';- if (src.startsWith('mfr-')) return 'mfr';- if (src.startsWith('mouser') || src.startsWith('digikey') || src.startsWith('ul-')) return 'fb';- return 'unk';+// ---- Live channel: one 1 s poll of /api/activity carries the heartbeat, server toasts, UI commands and the library version. ----+let activityTimer = null;+async function pollActivity() {+ try {+ const a = await api('/api/activity');+ for (const t of (a._toasts || [])) cfToast(t.message, { type: t.kind });+ for (const c of (a._ui || [])) handleUiCommand(c);+ if (a._state_version != null && a._state_version !== STATE_VERSION) {+ const first = STATE_VERSION == null; STATE_VERSION = a._state_version;+ if (!first) loadLibrary().catch(e => console.warn('library refresh failed', e));+ }+ const mpn = a && a.mpn ? a.mpn : null, stage = a && a.stage ? a.stage : '';+ const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;+ const terminal = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';+ const stale = age != null && age > (Number(SETTINGS.activity_stale_seconds) || 90);+ const working = mpn && !terminal && !stale;+ if (working) {+ const stageChanged = stage !== ACTIVE_STAGE; ACTIVE_STAGE = stage;+ if (mpn !== ACTIVE_MPN) {+ ACTIVE_MPN = mpn; render(); updateSubject(); scrollActiveCardIntoView();+ if (SETTINGS.auto_select_active && chipByMpn(mpn)) select(mpn);+ autoZoomIfEnabled(mpn);+ } else if (stageChanged) updateSubject();+ } else if (ACTIVE_MPN !== null) { ACTIVE_MPN = null; ACTIVE_STAGE = ''; render(); updateSubject(); }+ } catch (e) { /* transient */ } }--function srcShort(src) {- if (src === '—' || !src) return '?';- return src.replace('-fallback','').replace('mouser-','mouser/').replace('mfr-','');+function handleUiCommand(c) {+ try {+ switch (c.cmd) {+ case 'toast': cfToast(c.message || '', { type: c.kind }); break;+ case 'click': { const el = $id(c.id); if (el) { el.click(); } else cfToast('No element with id ' + c.id, { type: 'err' }); break; }+ case 'select': if (c.mpn) { select(c.mpn); const card = document.querySelector(`.card[data-mpn="${CSS.escape(c.mpn)}"]`); if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } break;+ case 'view': setView(c.view || 'grid'); break;+ case 'open_settings': openSettings(c.card); break;+ case 'close_settings': $id('dlgSettings').close(); break;+ case 'zoom': { const card = c.mpn && document.querySelector(`.card[data-mpn="${CSS.escape(c.mpn)}"]`); if (card) zoomCard(card); else cfToast('No card for ' + c.mpn, { type: 'err' }); break; }+ case 'unzoom': dismissZoom(); break;+ case 'reload': location.reload(); break;+ default: console.warn('unknown ui command', c);+ }+ } catch (e) { console.error('ui command failed', c.cmd, e); } } -function render() {- const q = document.getElementById('q').value.toLowerCase();- const v = document.getElementById('vendor').value;- const s = document.getElementById('status').value;- const cards = document.getElementById('cards');- cards.innerHTML = '';- const filtered = LIB.chips.filter(c => {- if (ACTIVE_PROJECT_FILTER && PROJECTS) {- const proj = PROJECTS.projects[ACTIVE_PROJECT_FILTER];- if (proj && !proj.mpns.includes(c.mpn)) return false;- }- if (q && !(c.mpn + ' ' + c.vendor + ' ' + c.package).toLowerCase().includes(q)) return false;- if (v && c.vendor !== v) return false;- if (s === 'full' && !c.fully_bundled) return false;- if (s === 'stub' && c.fully_bundled) return false;- if (s === 'no-info' && c.has_info) return false;- if (s === 'no-pdf' && c.has_pdf) return false;- return true;- });- if (filtered.length === 0) {- cards.innerHTML = '<div class="empty-state">no chips match filters</div>';- return;- }- // If there's an active chip that isn't in the filtered set, still surface it- // (so the user always sees what's being worked on) — but make it OBVIOUS- // it's outside the current filter, with a one-click way to clear, instead of- // silently injecting a card that contradicts the grid behind it.- let outOfFilterMpn = null;- if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {- const activeChip = LIB.chips.find(c => c.mpn === ACTIVE_MPN);- if (activeChip) { filtered.unshift(activeChip); outOfFilterMpn = ACTIVE_MPN; }- }- if (outOfFilterMpn) {- const where = ACTIVE_PROJECT_FILTER ? ('project “' + ACTIVE_PROJECT_FILTER + '”') : 'the current filter';- const note = document.createElement('div');- note.className = 'out-of-filter-note';- note.innerHTML = `Showing <b>${escapeHtml(outOfFilterMpn)}</b> — not in ${escapeHtml(where)}. <button class="ofn-clear" type="button">Clear filter</button>`;- note.querySelector('.ofn-clear').onclick = () => {- ACTIVE_PROJECT_FILTER = null;- document.getElementById('q').value = '';- document.getElementById('vendor').value = '';- document.getElementById('status').value = '';- renderProjectTabs();- render();- };- cards.appendChild(note);- }- for (const c of filtered) {- const card = document.createElement('div');- const cls = ['card'];- if (SELECTED === c.mpn) cls.push('selected');- if (ACTIVE_MPN === c.mpn) cls.push('active');- card.className = cls.join(' ');- card.dataset.mpn = c.mpn;- const tier = srcTier(c.step_source);- const m = (c.stock && c.stock.mouser) || null;- // Always show stock + price badges. "?" when we don't have data.- let stockBadge = '<span class="b stock-unk" data-tooltip="Stock level unknown.\nMouser stock data hasn\'t been fetched yet for this chip. Run a fetch to populate stock.json with current inventory + pricing.">stock ?</span>';- let priceBadge = '<span class="b price" data-tooltip="Unit price unknown.\nMouser pricing hasn\'t been fetched yet. Run a fetch to get price breaks from Mouser\'s API.">$?</span>';- let lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle status unknown.\nMouser hasn\'t been queried for this chip\'s lifecycle (Active / NRND / EOL / New). Run a fetch to populate.">lifecycle ?</span>';- if (m) {- const s = m.stock || 0;- const cls = s >= 1000 ? 'stock-yes' : s > 0 ? 'stock-low' : 'stock-no';- const label = s > 0 ? (s >= 1000 ? `${(s/1000).toFixed(s>=10000?0:1)}k in stock` : `${s} in stock`) : 'no stock';- stockBadge = `<span class="b ${cls}" data-tooltip="Mouser stock: ${s} units.\n${s>=1000?'Well stocked — safe for production quantities.':s>0?'Low stock — check lead time before committing.':'Out of stock at Mouser. Check DigiKey or JLCPCB as alternatives.'}">${label}</span>`;- priceBadge = m.unit_price- ? `<span class="b price" data-tooltip="Mouser unit price: ${escapeHtml(m.unit_price)}.\nPrice at qty 1. Click the Mouser link in the chip detail panel for price breaks at higher quantities.">${escapeHtml(m.unit_price)}</span>`- : '<span class="b price" data-tooltip="Price not available from Mouser for this chip.">$?</span>';- const lc = (m.lifecycle || '').toLowerCase();- if (/end of life|obsolete|discontinued|last.?time/.test(lc)) {- lifecycleBadge = `<span class="b eol" data-tooltip="End of Life (${escapeHtml(m.lifecycle)}).\nThis chip is discontinued. Do NOT use in new designs — it will become unavailable. chip-fetcher will suggest active replacements if available.">EOL — DON'T USE</span>`;- } else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) {- lifecycleBadge = `<span class="b nrnd" data-tooltip="Not Recommended for New Designs (${escapeHtml(m.lifecycle)}).\nStill available but the manufacturer is phasing it out. Use an active replacement for new designs; OK for existing boards.">NRND — AVOID FOR NEW DESIGNS</span>`;- } else if (/new product|new release/.test(lc)) {- lifecycleBadge = `<span class="b new" data-tooltip="New product (${escapeHtml(m.lifecycle)}).\nRecently released by the manufacturer. Stock may be limited initially but supply is ramping.">NEW</span>`;- } else if (lc) {- lifecycleBadge = '';- } else {- lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle field empty in Mouser\'s response.\nThe chip is listed but Mouser didn\'t report Active/NRND/EOL status. Usually means Active.">lifecycle ?</span>';- }- }- const pkgDisplay = (c.package && c.package !== '?' && c.package !== '—') ? c.package : '?';- const sizeDisplay = c.size_bytes > 0 ? `${(c.size_bytes/1024/1024).toFixed(1)} MB` : '? MB';- // Per-EDA-tool badges. Each tool gets one badge:- // green = both symbol + footprint present- // amber = partial (e.g. only schematic, or only footprint)- // grey = missing entirely (shows "?")- const ked = badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn);- const alt = badgeForEda('ALTIUM', c.has_altium_sch, c.has_altium_pcb, c.info, c.file_provenance, c.mpn);- const fus = badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn); // Fusion ships sym+mod in one .lbr- // Pin-1 alignment status from chipsmith: did the user line up the 3D chip's- // pin-1 with the footprint's pin-1 and sign off? Only meaningful once both the- // 3D model and the footprint exist.- let pin1Badge = '';- if (c.has_step && c.has_mod) {- const fp = c.fp_pin1_corner || null; // footprint's pin-1 corner = the target- if (c.pin1_signed) {- const corner = c.pin1_corner || '?';- const when = c.pin1_signed_at ? ' (signed ' + String(c.pin1_signed_at).slice(0,10) + ')' : '';- const meas = c.pin1_measure_ok === false ? '\nNote: chipsmith flagged a measurement mismatch.' : '';- if (fp && corner !== '?' && corner !== fp) {- pin1Badge = `<span class="b pin1bad" data-tooltip="Pin-1 MISMATCH${when}.\nThe 3D chip's pin-1 was signed off in the ${escapeHtml(corner)} corner, but the footprint's pin-1 is in the ${escapeHtml(fp)} corner. Re-align in chipsmith so the chip and footprint agree.${meas}">Pin-1 ⚠ chip ${escapeHtml(corner)} / fp ${escapeHtml(fp)}</span>`;- } else {- const agree = fp ? ' (matches the footprint)' : '';- pin1Badge = `<span class="b pin1ok" data-tooltip="Pin-1 aligned + signed off in chipsmith${when}.\nPin-1 sits in the ${escapeHtml(corner)} corner${agree}; the 3D chip is lined up to the footprint. ${escapeHtml(c.mpn)}.chipsmith.step is the canonical oriented model downstream tools use.${meas}">Pin-1 ✓ ${escapeHtml(corner)}</span>`;- }- } else {- const target = fp ? ' · fp ' + escapeHtml(fp) : '';- const tip = fp- ? `Pin-1 not yet aligned.\nThe footprint pin-1 is in the ${fp} corner (that's the target). Use the ‘Align pin-1’ button (opens chipsmith) to line up the 3D chip's pin-1 to it and sign off; chip-fetcher then shows the chip corner here + bakes ${escapeHtml(c.mpn)}.chipsmith.step.`- : `Pin-1 not yet aligned.\nThe 3D chip and footprint are both here. Use the ‘Align pin-1’ button (opens chipsmith) to line up pin-1 and sign off.`;- pin1Badge = `<span class="b pin1" data-tooltip="${tip}">Pin-1 ?${target}</span>`;- }- }- // Validation badge (concur status). Read straight from inventory.rs's- // validation_status field which we read from <MPN>-validation-status.json.- let valBadge = '';- if (c.validation_status === 'golden') {- valBadge = `<span class="b val-golden" data-tooltip="Golden dataset — every source (ds2sf from datasheet, KiCad UL bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names, and package family. Safe for downstream use.">✓ GOLDEN</span>`;- } else if (c.validation_status === 'golden_with_variants') {- const vs = (c.validation_variants || []).map(v => v.replace(/_/g, ' ')).join(', ');- valBadge = `<span class="b val-golden-v" data-tooltip="Golden with explainable variants.\nSources disagree on some axes, but every disagreement is benign:\n${escapeHtml(vs)}\nSafe for downstream — the variant list tells tools which source to prefer per axis.">✓ GOLDEN+${(c.validation_variants||[]).length}v</span>`;- } else if (c.validation_status === 'divergent') {- valBadge = `<span class="b val-divergent" data-tooltip="Divergent — at least one source disagrees and the disagreement is NOT explained by a known variant.\nClick ⟲ to re-validate, or open the concur result.json for the specific conflict axis + suggested refetch sources.">⚠ DIVERGENT</span>`;- } else if (c.validation_status === 'unrecoverable') {- valBadge = `<span class="b val-bad" data-tooltip="Unrecoverable — concur could not parse enough sources to run the comparison.\nCheck the chip directory for missing or corrupt files.">✗ FAIL</span>`;- } else {- valBadge = `<span class="b val-none" data-tooltip="Not yet validated.\nClick ⟲ to run ds2sf (extracts symbol + footprint from the datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium). Takes 30-60s per chip.">— UNVAL</span>`;- }- // NOTE: the datasheet-derived ("authoritative") footprint is NOT a separate- // tile anymore — it lives as the `ds2sf · datasheet` source on the FP tile's- // provenance carat (one tap to compare against the vendor footprint). A big- // white SVG tile here was redundant with the FP tile and broke dark mode.- card.innerHTML = `- <div class="mpn-row">- <div class="mpn">${escapeHtml(c.mpn)}</div>- <div class="card-actions">- <button class="card-btn" data-tooltip="Re-validate this chip.\nRuns ds2sf (extracts symbol + footprint from datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium libs). Updates the validation badge on this card when done." data-action="validate" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-validate">⟲</button>- <button class="card-btn" data-tooltip="Re-render thumbnails.\nRegenerates the symbol SVG, footprint SVG, and all 6 orientation 3D PNGs from the current library files. Useful after importing a new STEP or .kicad_sym." data-action="thumbnails" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-render thumbnails">🖼</button>- <select class="card-btn refetch-select" data-tooltip="Re-fetch a specific file from a different source.\nPick what you need and where to get it. Opens the source's product page in a browser window and watches for the download." data-mpn="${escapeHtml(c.mpn)}" aria-label="Refetch from source">- <option value="">⤓ re-fetch…</option>- <option value="manufacturer|kicad_sym">Manufacturer → Schematic symbol</option>- <option value="manufacturer|kicad_mod">Manufacturer → PCB footprint</option>- <option value="snapmagic|kicad_sym">SnapMagic → Schematic symbol</option>- <option value="snapmagic|kicad_mod">SnapMagic → PCB footprint</option>- <option value="mouser|kicad_sym">Mouser → Schematic symbol</option>- <option value="digikey|kicad_sym">DigiKey → Schematic symbol</option>- <option value="cse_ul|kicad_sym">Ultra Librarian → Schematic symbol</option>- <option value="manufacturer|step">Manufacturer → 3D STEP model</option>- <option value="manufacturer|pdf">Manufacturer → Datasheet PDF</option>- </select>- <button class="reveal-btn" data-tooltip="Open this chip's folder in VS Code Explorer.\nReveals library/${escapeHtml(c.mpn)}/ in the sidebar so you can see every file (STEP, KiCad sym/mod, datasheets, info.json, provenance logs)." data-reveal-mpn="${escapeHtml(c.mpn)}" aria-label="Reveal ${escapeHtml(c.mpn)} in VS Code Explorer"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button>- <button class="card-btn zoom-btn" data-tooltip="Zoom this chip card into a centered overlay for close inspection (tap the backdrop to close). On a phone this is the way to enlarge a card — double-tap is reserved by the browser for page zoom." data-zoom-mpn="${escapeHtml(c.mpn)}" aria-label="Zoom ${escapeHtml(c.mpn)} card"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/></svg></button>- </div>- </div>- <div class="meta">${escapeHtml(c.vendor || '?')} · ${escapeHtml(pkgDisplay)} · ${sizeDisplay}</div>- ${(c.info && (c.info.summary || c.info.description)) ? `<div class="card-desc" data-tooltip="${escapeHtml(c.info.summary || c.info.description)}">${escapeHtml(c.info.summary || c.info.description)}</div>` : ''}- <div class="thumbs">- <!-- Primary row: the two artifacts you design with — biggest. -->- <div class="thumb-row primary">- ${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', null, thumbErr(c, 'sym'), c.symbol_sources)}- ${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', null, thumbErr(c, 'fp'), c.footprint_sources)}- </div>- <!-- Secondary row: 3D up-axis QA + the .lbr packaging action — smaller. -->- <div class="thumb-row secondary">- <div class="thumb-3d-group">${render3dGroup(c)}</div>- ${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — the ONE EAGLE library that Fusion 360 / Altium / OrCAD all import directly. Lints it, then one-click Send to your desktop EDA tool. KiCad isn't in that list on purpose: KiCad can't read .lbr — it uses its own native .kicad_sym + .kicad_mod, which you already have (the KICAD capsule / Send → KiCad)." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}- </div>- </div>- <div class="badges">- <span class="b ${c.fully_bundled?'full':'stub'}" data-tooltip="${c.fully_bundled?'Fully bundled — has STEP + KiCad symbol + KiCad footprint + Altium + Fusion .lbr.\nThis chip is ready for all downstream tools (chipsmith, InstaPCB, molecule creation).':'Stub — missing one or more core CAD files (STEP, KiCad sym/mod, Altium, Fusion).\nClick ⟲ to validate, or use the ⤓ dropdown to refetch missing files from a specific source.'}">${c.fully_bundled?'FULL':'STUB'}</span>- ${valBadge}- ${lifecycleBadge}- ${stockBadge}- ${priceBadge}- <span class="b ${c.has_step?'step':'miss'}" data-tooltip="${c.has_step?'Source: ' + resolveStepSource(c).label + '\n\nThe 3D STEP model — used by chipsmith for pin detection + footprint validation, and by chip-thumbnailer for the orientation renders above.':'3D STEP model missing.\nNeeded for chipsmith validation + 3D thumbnails.'}">STEP${c.has_step?'':' ?'}</span>- ${ked}- ${alt}- ${fus}- <span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?'Source: ' + fileSource(c, /\.pdf$/i).label + '\n\n' + c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>- <span class="b src ${tier}" data-tooltip="${(function(){var r=resolveStepSource(c);return r.found?'STEP source: '+r.label+'.\nThis is where the 3D model came from. Ladder priority: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC.':'STEP source '+r.label+'.\nchip-fetcher downloaded this STEP but did not record where from. Refetch to capture it.';})()}">${escapeHtml((function(){var r=resolveStepSource(c);return r.found?srcShort(r.label):'src ?';})())}</span>- ${pin1Badge}- </div>- `;- card.onclick = (ev) => {- const rb = ev.target.closest('.reveal-btn');- if (rb) { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); return; }- // Per-card action buttons (validate / thumbnails) — POST to the- // matching /api/<action>/<MPN> endpoint, the server spawns- // chip-fetcher as a subprocess. Dashboard auto-refreshes from- // /api/library polling.- const ab = ev.target.closest('.card-btn[data-action]');- if (ab) {- ev.stopPropagation();- const action = ab.dataset.action;- const mpn = ab.dataset.mpn;- ab.disabled = true;- ab.style.opacity = '0.5';- fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' })- .then(r => r.json())- .then(j => {- console.log('[' + action + '] kicked:', j);- // The subprocess heartbeats; the dashboard's activity poller- // will pick that up + render the WORKING badge.- })- .catch(e => console.error('[' + action + '] failed:', e))- .finally(() => {- // Leave the button disabled briefly so the user gets visual- // confirmation; re-enable after 3s. The actual work continues- // in the background.- setTimeout(() => { ab.disabled = false; ab.style.opacity = ''; }, 3000);- });- return;- }- const tb = ev.target.closest('.thumb[data-thumb-kind]');- if (tb) {- ev.stopPropagation();- const flipTo = tb.dataset.orientFlip;- if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); return; }- // Symbol/footprint tiles are NOT clickable to open the app — that- // conflicted with the source-carat menu sitting on them. Use the carat to- // switch source and the "Open in" buttons to launch. 3D/datasheet still open.- if (tb.dataset.thumbKind === 'symbol' || tb.dataset.thumbKind === 'footprint') return;- openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);- return;- }- // Single-click SELECTS the chip → fills the right-hand detail panel- // (the "select a chip to see details" pane). Double-click zooms the- // card into a centered overlay for close inspection.- select(c.mpn);- };- card.ondblclick = (ev) => {- if (ev.target.closest('.card-btn, .reveal-btn, .thumb, .refetch-select, .orient-more-btn, .orient-popover')) return;- zoomCard(card);+// ---- "Say it": phrases come from dockbar.json via /api/say so the dock and this strip teach the same words. ----+async function buildSay() {+ let phrases = [];+ try { phrases = (await api('/api/say')).phrases || []; } catch (e) {}+ const list = $id('sayList'); list.innerHTML = '';+ if (!phrases.length) { $id('say').hidden = true; return; }+ $id('say').hidden = false;+ for (const p of phrases) {+ const item = document.createElement('div'); item.className = 'say-item';+ const b = document.createElement('button'); b.className = 'say-phrase'; b.type = 'button';+ b.setAttribute('data-tooltip', 'Copy this phrase to your clipboard, then paste it to your AI.');+ b.innerHTML = '“' + escapeHtml(p).replace(/chip ?fetcher/gi, m => '<em>' + m + '</em>') + '”';+ b.onclick = async () => { try { await navigator.clipboard.writeText(p); cfToast('Copied: ' + p, { type: 'ok' }); } catch (e) { cfToast('Could not copy: ' + e.message, { type: 'err' }); } };+ const t = document.createElement('button'); t.className = 'say-try'; t.type = 'button'; t.textContent = 'Try it';+ t.setAttribute('data-tooltip', "Stage this phrase in this app's AI thread. It is not sent; you press Enter there.");+ t.onclick = async () => {+ t.disabled = true;+ try {+ const d = await (await postJson('/api/say/try', { phrase: p })).json();+ if (d.staged) cfToast('Staged in your AI thread' + (d.where ? ' (' + d.where + ')' : '') + ': ' + p + '. Press Enter there to send.', { type: 'ok' });+ else { try { await navigator.clipboard.writeText(p); } catch (e) {} cfToast('Copied instead: ' + (d.reason || 'could not reach your AI thread') + '. Paste it into your AI.'); }+ } catch (e) { cfToast('Try failed: ' + e.message, { type: 'err' }); }+ finally { t.disabled = false; } };- // Tap-to-zoom (touch-friendly — phones reserve double-tap for page zoom).- const zb = card.querySelector('.zoom-btn');- if (zb) zb.onclick = (ev) => { ev.stopPropagation(); zoomCard(card); };- // Orient-more popover toggle + pick- const orientBtn = card.querySelector('.orient-more-btn');- const orientPop = card.querySelector('.orient-popover');- if (orientBtn && orientPop) {- orientBtn.onclick = (ev) => {- ev.stopPropagation();- orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';- };- orientPop.querySelectorAll('.orient-opt').forEach(opt => {- opt.onclick = (ev) => {- ev.stopPropagation();- const axis = opt.dataset.axis;- const mpn = opt.dataset.mpn;- orientPop.style.display = 'none';- setOrientation(mpn, axis);- };- });- }-- // Refetch dropdown — separate handler because <select> needs change- // event, not click.- const refSel = card.querySelector('.refetch-select');- if (refSel) {- refSel.onchange = (ev) => {- ev.stopPropagation();- const v = ev.target.value;- if (!v) return;- const [source, format] = v.split('|');- const mpn = ev.target.dataset.mpn;- ev.target.value = ''; // reset selector- fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) +- '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),- { method: 'POST' })- .then(r => r.json())- .then(j => console.log('[refetch] kicked:', j))- .catch(e => console.error('[refetch] failed:', e));- };- refSel.onclick = (ev) => ev.stopPropagation();- }- cards.appendChild(card);+ item.appendChild(b); item.appendChild(t); list.appendChild(item); } } -function autoZoomIfEnabled(mpn) {- const cb = document.getElementById('auto-zoom-cb');- if (!cb || !cb.checked) return;- // Dismiss any existing zoom- const existing = document.querySelector('.card-zoom-backdrop');- if (existing) existing.remove();- // Find the card and zoom it- const card = document.querySelector(`[data-mpn="${CSS.escape(mpn)}"]`);- if (card) {- // Small delay so the render settles first- setTimeout(() => zoomCard(card), 100);+// ---- Component LEDs: the compact row under the Say-it strip, and the Components card in Settings. ----+const LED_ORDER = ['dashboard', 'skill', 'tools', 'bridge', 'library'];+function ledKeys(st) { const leds = (st && st.leds) || {}; return LED_ORDER.filter(k => leds[k]).concat(Object.keys(leds).filter(k => !LED_ORDER.includes(k))); }+function renderLeds() {+ const host = $id('leds'); host.innerHTML = '';+ if (!STATUS || !STATUS.leds) return;+ for (const k of ledKeys(STATUS)) {+ const l = STATUS.leds[k];+ const el = document.createElement('div'); el.className = 'ledchip';+ el.setAttribute('data-tooltip', (l._hint || '') + (l.state !== 'ok' ? '\nOpen Settings to see the Install button for this component.' : ''));+ el.innerHTML = `<span class="led ${escapeHtml(l.state)}"></span><span class="lab">${escapeHtml(l.label || k)}</span><span class="det">${escapeHtml(l.detail || '')}</span>`;+ el.onclick = () => openSettings('card-components');+ el.style.cursor = 'pointer';+ host.appendChild(el); } }--function zoomCard(cardEl) {- // PROGRAMMATIC ANTI-STACK GUARD. pollActivity (1s), the library refresh (4s),- // auto-zoom, and manual clicks can all call zoomCard within the same frame —- // autoZoomIfEnabled's remove-then-setTimeout(100) loses the race when two- // callers both pass the "none open" check before either timeout fires, so two- // backdrops get created and stack. The only robust fix is to dedup HERE, at- // the moment of creation: there is ever only ONE .card-zoom-backdrop. If one- // is already open for this same chip, do nothing; otherwise replace it.- const alreadyOpen = document.querySelector('.card-zoom-backdrop');- if (alreadyOpen) {- if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return;- alreadyOpen.remove();- }- // Animate FROM the card's grid position TO centered on screen so- // the user can track which card is zooming. The clone starts at- // the card's exact rect (scale 1) and transitions to the final- // centered + scaled position.- const rect = cardEl.getBoundingClientRect();- // Phone: fill the browser width (no 1.9 cap) and allow vertical scroll.- const phone = matchMedia('(hover: none)').matches || window.innerWidth < 760;- // The FINAL scale is computed AFTER the clone is in the DOM, from its REAL- // height (which includes the injected Open-in/Send-to actions + status banner).- // Computing it here from the original card height clipped those off the bottom.- const backdrop = document.createElement('div');- backdrop.className = 'card-zoom-backdrop';- backdrop.dataset.mpn = cardEl.dataset.mpn || '';- const wrapper = document.createElement('div');- wrapper.className = 'card-zoomed-wrapper';- const clone = cardEl.cloneNode(true);- clone.style.width = rect.width + 'px';- clone.style.transformOrigin = 'top left';- // Inject Open-in + Send-to into the zoom focus view so the chip's actions are- // reachable here (especially on a phone, where the side panel is cramped).- // The .openbtn/.sendbtn handlers are delegated on document, so they just work.- const zchip = (LIB && LIB.chips || []).find(x => x.mpn === cardEl.dataset.mpn);- if (zchip) {- const open = [- zchip.has_sym && ['symbol', 'adom-symbol'],- zchip.has_mod && ['footprint', 'adom-footprint'],- (zchip.has_sym || zchip.has_mod) && ['lbr', 'adom-lbr'],- zchip.has_step && ['chip3d', 'adom-3D'],- zchip.has_step && ['chipsmith', 'chipsmith'],- (zchip.symbol_sources || []).some(s => s.method === 'ds2sf') && ['ds2sf', 'ds2sf extraction'],- ].filter(Boolean);- const sec = document.createElement('div');- sec.className = 'zoom-actions';- sec.innerHTML =- `<div class="zoom-sec">Open in</div><div class="openin-row" data-mpn="${escapeHtml(zchip.mpn)}">`- + open.map(([k, l]) => `<button class="openbtn" data-kind="${k}">${l}</button>`).join('')- + `</div><div class="zoom-sec">Send to</div><div class="sendto-row" data-mpn="${escapeHtml(zchip.mpn)}">`- + [['kicad', 'KiCad'], ['fusion360', 'Fusion'], ['altium', 'Altium'], ['orcad', 'OrCAD']]- .map(([t, l]) => `<button class="sendbtn" data-target="${t}">${l}</button>`).join('')- + `</div>`;- clone.appendChild(sec);- }- // Fluid 2-column layout (desktop): move the media (.thumbs) into a left column- // and everything else into a right column, so the zoom uses the horizontal room- // and is short enough to scale up big. Children stay descendants of the clone,- // so the click re-wiring below still finds them.- if (!phone) {- const media = document.createElement('div'); media.className = 'zoom-col-media';- const info = document.createElement('div'); info.className = 'zoom-col-info';- Array.from(clone.children).forEach(ch => {- (ch.classList.contains('thumbs') ? media : info).appendChild(ch);- });- clone.appendChild(media);- clone.appendChild(info);- clone.classList.add('zoom-fluid');- clone.style.width = Math.min(900, Math.round(window.innerWidth * 0.82)) + 'px';- }- // Start at the card's grid position (scale 1)- wrapper.style.left = rect.left + 'px';- wrapper.style.top = rect.top + 'px';- clone.style.transform = 'scale(1)';- // Show a "working on" banner ONLY while THIS chip is actively being- // fetched. ACTIVE_MPN/ACTIVE_STAGE are maintained by pollActivity and are- // null when idle/stale — so an idle chip shows no banner at all (it used to- // inject an empty "working on" box, which read as broken).- const zoomMpn = cardEl.dataset.mpn;- function syncZoomStatus() {- const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;- // The status banner floats on the BACKDROP (fixed, top-center) — NOT inside- // the card. Injecting it into the card reflowed + rescaled the whole layout- // every time it appeared/disappeared, which read as "wacky". As an overlay- // pill it never touches the card's geometry; the liked layout stays put.- let bar = backdrop.querySelector('.zoom-status');- if (working) {- if (!bar) {- bar = document.createElement('div');- bar.className = 'zoom-status';- bar.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:20;background:rgba(0,184,176,.18);border:1px solid var(--accent);border-radius:999px;padding:8px 18px;font-size:13px;font-weight:600;color:var(--accent);font-family:Satoshi,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,.45);backdrop-filter:blur(6px);white-space:nowrap;';- backdrop.appendChild(bar);- }- bar.textContent = '● working on — ' + ACTIVE_STAGE;- } else if (bar) {- bar.remove();- }- }- syncZoomStatus();- const statusPoll = setInterval(syncZoomStatus, 1500);- wrapper.appendChild(clone);- backdrop.appendChild(wrapper);- // Dismiss with reverse animation (declared before use by the X button).- function dismiss() {- clearInterval(statusPoll);- backdrop.classList.remove('visible');- if (!phone) { wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)'; }- setTimeout(() => backdrop.remove(), 300);- document.removeEventListener('keydown', escHandler);- }- // Always-visible close (X) so it's obvious how to get back to the index.- const xbtn = document.createElement('button');- xbtn.className = 'card-zoom-x'; xbtn.setAttribute('aria-label', 'Close'); xbtn.textContent = '✕';- xbtn.onclick = (e) => { e.stopPropagation(); dismiss(); };- backdrop.appendChild(xbtn);- document.body.appendChild(backdrop);- // Fit the WHOLE card — including the injected Open-in/Send-to actions + any- // status banner — on screen with ONE zoom factor: the largest scale that fits- // BOTH the width and the card's REAL height. So it fills as much of the room as- // it can, is always fully visible, and NEVER scrolls or clips. (Measure the- // clone's natural height now that it's in the DOM at scale 1.)- // Fit the card to the viewport. CRITICAL: measure the clone's size AFTER the- // 2-column fluid layout + thumbnails have settled. Measuring synchronously- // here reads the TALL single-column height (before the grid reflows to wide-- // and-short), so the scale comes out too small and the card ends up a cramped- // little box floating in empty space — exactly the "weird at this zoom" bug.- function fitZoom() {- const naturalW = clone.offsetWidth || rect.width;- const naturalH = clone.offsetHeight || rect.height;- const scale = Math.min(- (window.innerWidth * (phone ? 0.96 : 0.94)) / naturalW,- (window.innerHeight * 0.94) / naturalH- );- const scaledW = naturalW * scale, scaledH = naturalH * scale;- wrapper.style.left = Math.max(8, (window.innerWidth - scaledW) / 2) + 'px';- wrapper.style.top = Math.max(8, (window.innerHeight - scaledH) / 2) + 'px';- clone.style.transition = 'transform .3s ease-out';- clone.style.transform = `scale(${scale})`;- }- backdrop.classList.add('visible');- // Double-rAF so the fluid 2-col layout has reflowed before we measure…- requestAnimationFrame(() => requestAnimationFrame(fitZoom));- // …and re-fit when a late-loading thumbnail changes the card's height.- clone.querySelectorAll('img').forEach(img => {- if (!img.complete) img.addEventListener('load', fitZoom, { once: true });- });- // Re-fit on browser zoom / window resize so it never goes weird at non-100%.- const onResize = () => fitZoom();- window.addEventListener('resize', onResize);- backdrop.addEventListener('remove', () => window.removeEventListener('resize', onResize));- backdrop.onclick = (ev) => { if (ev.target === backdrop) dismiss(); };- const escHandler = (ev) => { if (ev.key === 'Escape') dismiss(); };- document.addEventListener('keydown', escHandler);- // Re-wire ALL interactive elements inside the zoom clone.- // The clone is a dead DOM copy — no event listeners survived.- // Every clickable thing must be re-attached here.-- // 1. Thumbnail clicks: open viewer OR flip orientation- clone.querySelectorAll('.thumb[data-thumb-kind]').forEach(tb => {- tb.onclick = (ev) => {- ev.stopPropagation();- const flipTo = tb.dataset.orientFlip;- if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); dismiss(); return; }- openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);- };- tb.style.cursor = 'pointer';- });-- // 2. Orient-more popover (▾ button + option tiles)- const orientBtn = clone.querySelector('.orient-more-btn');- const orientPop = clone.querySelector('.orient-popover');- if (orientBtn && orientPop) {- orientBtn.onclick = (ev) => {- ev.stopPropagation();- orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';- };- clone.querySelectorAll('.orient-opt').forEach(opt => {- opt.onclick = (ev) => {- ev.stopPropagation();- setOrientation(opt.dataset.mpn, opt.dataset.axis);- dismiss();- };- });- }-- // 3. Action buttons (⟲ validate, 🖼 thumbnails)- clone.querySelectorAll('.card-btn[data-action]').forEach(ab => {- ab.onclick = (ev) => {- ev.stopPropagation();- fetch(API_BASE + '/api/' + ab.dataset.action + '/' + encodeURIComponent(ab.dataset.mpn), { method: 'POST' });- };- });-- // 4. Refetch dropdown- const refSel = clone.querySelector('.refetch-select');- if (refSel) {- refSel.onchange = (ev) => {- ev.stopPropagation();- const v = ev.target.value;- if (!v) return;- const [source, format] = v.split('|');- const mpn = ev.target.dataset.mpn;- ev.target.value = '';- fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) +- '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),- { method: 'POST' });- };- refSel.onclick = (ev) => ev.stopPropagation();- }-- // 5. Reveal folder button- clone.querySelectorAll('.reveal-btn[data-reveal-mpn]').forEach(rb => {- rb.onclick = (ev) => { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); };- });-- // 6. PDF badge click (open datasheet)- clone.querySelectorAll('[data-thumb-mpn][data-thumb-kind="datasheet"]').forEach(pdf => {- pdf.onclick = (ev) => { ev.stopPropagation(); openThumb(pdf.dataset.thumbMpn, 'datasheet'); };- pdf.style.cursor = 'pointer';- });+async function loadStatus(force) {+ try {+ STATUS = await api('/api/status' + (force ? '?force=1' : ''));+ if (STATUS.version) { SERVER_VERSION = STATUS.version; $id('popVersion').textContent = 'v' + SERVER_VERSION; $id('settingsVersion').textContent = 'v' + SERVER_VERSION; }+ renderLeds(); renderComponents();+ } catch (e) { console.warn('status failed', e); } } -function escapeHtml(s) {- return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);+// ---- Provenance helpers ----+function srcTier(src) { src = src || ''; if (src.startsWith('mfr')) return 'mfr'; if (src.startsWith('mouser') || src.startsWith('digikey') || src.startsWith('ul-')) return 'fb'; return 'unk'; }+function srcShort(src) { if (!src || src === DASH) return '?'; return src.replace('-fallback', '').replace('mouser-', 'mouser/').replace('mfr-', ''); }+function fileSource(c, extRe, fallbackField, opts) {+ opts = opts || {};+ const fp = c.file_provenance, info = c.info || {};+ const siteOf = url => { try { return (new URL(url)).hostname.replace(/^www\./, ''); } catch (e) { return ''; } };+ const MFR = s => s === 'manufacturer' || s === 'mfr' || s === 'vendor';+ const KIC = s => /^kicad(-library|_community)?$/.test(s);+ const AGG = s => KIC(s) || ['cse', 'componentsearch', 'cse_ul', 'samacsys', 'snapeda', 'snapmagic', 'ultralibrarian', 'ul', 'mouser', 'digikey', 'arrow', 'lcsc'].includes(s);+ function labelFor(e) {+ let made = (e.content_origin || '').toLowerCase(), found = (e.discovery_source || '').toLowerCase();+ const site = siteOf(e.url || '');+ if (made === 'unknown') made = ''; if (found === 'unknown') found = '';+ if (opts.requireManufacturer && (AGG(found) || AGG(made))) return null;+ if (made === 'adom-wiki' || found === 'adom-wiki') return 'Adom Wiki' + (site ? ', ' + site : '');+ if (KIC(made) || KIC(found)) return 'KiCad standard library';+ if (MFR(made) || MFR(found)) return site ? 'manufacturer, ' + site : 'manufacturer (site unrecorded)';+ if (site && (made || found)) return (made || found) + ', ' + site;+ if (made || found) { let lbl = made || found; if (made && found && found !== made) lbl = made + ' (via ' + found + ')'; return lbl; }+ return site || '';+ }+ if (fp && typeof fp === 'object') for (const fn in fp) { if (!extRe.test(fn)) continue; const l = labelFor(fp[fn] || {}); if (l) return { label: l, found: true }; }+ if (fallbackField && !opts.requireManufacturer) { const v = (c[fallbackField] || '').trim(); if (v && v !== DASH && v.toLowerCase() !== 'none') return { label: v, found: true }; }+ if (!opts.requireManufacturer) for (const e of (info.fetched_via_chain || [])) { const o = (e.outcome || '').toLowerCase(); if (o.includes('ok') || o.includes('success') || o === 'got it') return { label: e.source || '?', found: true }; }+ if (opts.requireManufacturer) return { label: "not from the manufacturer yet; re-fetch the datasheet from the maker's site", found: false };+ return { label: 'not recorded; re-fetch to capture it', found: false }; }--// One thumbnail tile. Clickable when the source file is present —-// click dispatches to the right Adom app (3D → adom-step, PDF →-// adom-pdf-viewer, sym/fp → full-size SVG in a pup tab). Empty slot-// shows a dashed placeholder so the user can see what's coming.-// Returns the chip-thumbnailer failure reason for a thumbnail kind, or null.-// Keys in .thumb-errors.json are "sym" / "fp" / "3d".-function thumbErr(c, key) {- const e = c && c.thumb_errors;- return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null;+function resolveStepSource(c) { return fileSource(c, /\.step$/i, 'step_source'); }+function src3dChip(c) {+ if (!c.has_step) return '';+ const r = resolveStepSource(c), s = (r.label || '').toLowerCase();+ let short = r.label;+ if (/manufacturer|mfr|direct/.test(s)) short = 'Mfr direct'; else if (/adom wiki/.test(s)) short = 'Adom'; else if (/kicad/.test(s)) short = 'KiCad lib'; else if (/ultralib/.test(s)) short = 'Ultra Librarian';+ else if (/snap/.test(s)) short = 'SnapMagic'; else if (/mouser|cse|samacsys/.test(s)) short = 'Mouser CSE'; else if (/digikey/.test(s)) short = 'DigiKey'; else if (/lcsc/.test(s)) short = 'LCSC';+ else if (!r.found || /not recorded|unknown|\?/.test(s)) short = 'source ?';+ return `<div class="src-chip" data-tooltip="${escapeHtml('3D model source: ' + r.label + '\nThe model colour is the CAD author\'s choice, not its source; this chip names the real source.')}"><span class="src-dot vendor"></span><span class="src-chip-lab">${escapeHtml(short)}</span></div>`; }--// The six face-up orientations: thumbnail kind + label per axis token.+function thumbErr(c, key) { const e = c && c.thumb_errors; return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null; } function axis3dInfo(axis) {- return ({- z: { kind: 'chip3d', label: 'Z↑' },- y: { kind: 'chip3d-yup', label: 'Y↑' },- xup: { kind: 'chip3d-xup', label: 'X↑' },- xdown: { kind: 'chip3d-xdown', label: 'X↓' },- ydown: { kind: 'chip3d-ydown', label: 'Y↓' },- zdown: { kind: 'chip3d-zdown', label: 'Z↓' },- })[axis] || { kind: 'chip3d-' + axis, label: axis };+ return ({ z: { kind: 'chip3d', label: 'Z up' }, y: { kind: 'chip3d-yup', label: 'Y up' }, xup: { kind: 'chip3d-xup', label: 'X up' }, xdown: { kind: 'chip3d-xdown', label: 'X down' }, ydown: { kind: 'chip3d-ydown', label: 'Y down' }, zdown: { kind: 'chip3d-zdown', label: 'Z down' } })[axis] || { kind: 'chip3d-' + axis, label: axis }; }+const ALL_AXES = ['z', 'y', 'xup', 'xdown', 'ydown', 'zdown']; -// The 3D thumbnail group. Slot 1 is always Z↑; slot 2 shows the CHOSEN-// orientation whenever it isn't z/y — so picking X↑/X↓/Y↓/Z↓ from the dropdown-// SWAPS that render into the visible main view (marked ✓), instead of the choice-// hiding inside the closed dropdown. The dropdown lists the other four.-function render3dGroup(c) {- const chosen = c.chosen_up_axis;- const slot2 = (chosen && chosen !== 'z' && chosen !== 'y') ? chosen : 'y';- const mainAxes = ['z', slot2];- const dropAxes = ['y', 'xup', 'xdown', 'ydown', 'zdown'].filter(a => a !== slot2);- const present = ax => ax === 'z' ? c.has_thumb_chip3d : ax === 'y' ? c.has_thumb_chip3d_yup : true;- const main = mainAxes.map(ax => {- const ai = axis3dInfo(ax);- return renderThumb(c.mpn, ai.kind, present(ax), ai.label,- { upAxis: ax, chosen: chosen, inferred: c.inferred_up_axis }, thumbErr(c, '3d'));- }).join('');- const drop = dropAxes.map(o => {- const ai = axis3dInfo(o);- const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + ai.kind + '?t=' + Date.now();- const isChosen = chosen === o;- return `<div class="orient-opt${isChosen ? ' chosen' : ''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${ai.label} 3D rendering as the correct up-axis — it swaps into the main view.">- <img src="${url}" onerror="this.parentElement.style.display='none'" /><span>${ai.label}</span></div>`;- }).join('');- return `${srcChipHtml(c)}- ${main}- <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations. The one you pick swaps into the main view and is marked ✓. Saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>- <div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">${drop}</div>`;-}--// Tracks which chips' SYM tiles are mid-regen (after an up-axis switch). Keyed-// by the symbol revision (mtime of <mpn>-symbol.svg) captured at click-time: the-// spinner clears itself the moment a newer sym_rev arrives on a poll — or after a-// 95s safety cap so a failed regen can't spin forever.-const SYM_REFRESHING = new Map(); // mpn -> { at, rev }-function chipByMpn(mpn) { return ((LIB && LIB.chips) || []).find(x => x.mpn === mpn); }-function markSymRefreshing(mpn) {- SYM_REFRESHING.set(mpn, { at: Date.now(), rev: (chipByMpn(mpn) || {}).sym_rev });-}+// Symbol re-render spinner bookkeeping (after an up-axis switch), keyed by sym_rev; 95 s safety cap.+const SYM_REFRESHING = new Map();+function markSymRefreshing(mpn) { SYM_REFRESHING.set(mpn, { at: Date.now(), rev: (chipByMpn(mpn) || {}).sym_rev }); } function isSymRefreshing(mpn) {- const e = SYM_REFRESHING.get(mpn);- if (!e) return false;+ const e = SYM_REFRESHING.get(mpn); if (!e) return false; const c = chipByMpn(mpn); if (Date.now() - e.at > 95000 || (c && c.sym_rev !== e.rev)) { SYM_REFRESHING.delete(mpn); return false; } return true; } -function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {+function renderThumb(mpn, kind, present, label, errMsg, sources, tipText) { if (!present) {- // Distinguish a FAILED render (chip-thumbnailer errored) from one that was- // never attempted — a blank tile next to a green KICAD/STEP chip otherwise- // reads as "no file", hiding a real generation failure.- if (errMsg) {- return `<div class="thumb failed" data-tooltip="${escapeHtml(label)} render failed: ${escapeHtml(errMsg)}\nClick the ⟲ / 🖼 card buttons to re-validate or re-render."><span class="thumb-failed">⚠ ${escapeHtml(label)}</span><span class="thumb-failed-sub">render failed</span></div>`;- }- return `<div class="thumb empty" data-tooltip="${escapeHtml(label)} thumbnail not yet generated.\nUse the 🖼 card button to render it from the current library files."><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`;+ if (errMsg) return `<div class="thumb failed" data-tooltip="${escapeHtml(label + ' render failed: ' + errMsg + '\nUse the re-render thumbnails button on the card to try again.')}"><span class="thumb-failed">${escapeHtml(label)}</span><span class="thumb-failed-sub">render failed</span></div>`;+ return `<div class="thumb empty" data-tooltip="${escapeHtml(label + ' thumbnail not generated yet.\nUse the re-render thumbnails button on the card to render it from the current library files.')}"><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`; }- // Show the ACTIVE source's thumbnail on the main tile: the canonical render- // for the current source, or the `.<method>` variant when the user picked one. let methodSuffix = '';- if (sources && sources.length) {- const am = activeMethod(mpn, kind, sources);- const amS = sources.find(s => s.method === am);- if (amS && !amS.current && amS.thumb) methodSuffix = '.' + am;- }- const url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + methodSuffix;- const src = url + '?t=' + Date.now();-- // Orientation-picker affordances on the two 3D tiles. When `orient` is- // passed, decorate active vs inactive: active gets a green border + corner- // check, inactive dims to 55% until hover with a "click to mark this- // orientation as correct" hint in the tooltip.- let orientAttrs = '';- let orientCheck = '';- let titleSuffix = '';- if (orient && kind.indexOf('chip3d') === 0) {- const isActive = orient.chosen === orient.upAxis;- if (isActive) {- orientAttrs = ' data-orient-active="true"';- orientCheck = `<span class="orient-check" aria-label="active orientation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;- const inferredMatches = orient.inferred === orient.upAxis;- titleSuffix = inferredMatches- ? ` · active (auto-detected ${orient.inferred.toUpperCase()}-up)`- : ` · active (you picked, overriding inferred ${orient.inferred.toUpperCase()}-up)`;- } else {- orientAttrs = ` data-orient-inactive="true" data-orient-flip="${orient.upAxis}"`;- titleSuffix = ` · click to mark this ${orient.upAxis.toUpperCase()}-up rendering as correct`;- }- }-- const openTip = {- symbol: `Open ${mpn} in the live adom-symbol app — interactive schematic symbol rendered via service-kicad (no local KiCad).`,- footprint: `Open ${mpn} in the live adom-footprint app — interactive PCB footprint rendered via service-kicad (no local KiCad).`,- chip3d: `Open ${mpn} in the adom-step 3D viewer — orbit the real STEP model.`,- 'chip3d-yup': `Open the Y-up 3D render of ${mpn} full-size (Fusion 360 / SolidWorks convention).`,- datasheet: `Open the ${mpn} datasheet PDF in the viewer.`,- }[kind] || `Open ${mpn} ${label} in Adom.`;- // If a per-source variant thumbnail is missing/failed, fall back to the- // canonical render (no broken image). Only one fallback hop.+ if (sources && sources.length) { const am = activeMethod(mpn, kind, sources), amS = sources.find(s => s.method === am); if (amS && !amS.current && amS.thumb) methodSuffix = '.' + am; }+ const url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + methodSuffix; const canonUrl = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind;- const onerr = methodSuffix- ? `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${canonUrl}?t='+Date.now()}else{this.style.visibility='hidden'}"`- : `onerror="this.style.visibility='hidden'"`;- // Symbol/footprint tiles are NON-interactive (no open-on-click, no hover- // tooltip) so they don't fight the source-carat menu sitting on them.+ const onerr = methodSuffix ? `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${canonUrl}?t='+Date.now()}else{this.style.visibility='hidden'}"` : `onerror="this.style.visibility='hidden'"`; const noHover = (kind === 'symbol' || kind === 'footprint');- const tipAttr = noHover ? '' : ` data-tooltip="${escapeHtml(openTip + titleSuffix)}"`;- // SYM tile shows a spinner while its outline+symbol regenerate (up-axis switch). const refreshing = (kind === 'symbol' && isSymRefreshing(mpn));- const spinner = refreshing ? '<span class="thumb-spinner" aria-label="re-rendering symbol"></span>' : '';- const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}${refreshing ? ' refreshing' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>- <img src="${src}" alt="${escapeHtml(mpn)} ${escapeHtml(label)}" loading="lazy" ${onerr}>- <span class="thumb-label">${escapeHtml(label)}</span>- ${orientCheck}- ${spinner}- </div>`;- // Symbol/footprint provenance carat: an always-visible, tappable source chip- // (works on touch) + a popover listing every method that produced a variant.- if ((kind === 'symbol' || kind === 'footprint') && sources && sources.length) {- return `<div class="thumb-src-group">${thumbHtml}${srcCarat(mpn, kind, sources)}</div>`;- }- return thumbHtml;+ const html = `<div class="thumb${noHover ? ' thumb-static' : ''}${refreshing ? ' refreshing' : ''}"${tipText ? ` data-tooltip="${escapeHtml(tipText)}"` : ''} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}">+ <img src="${url}?t=${Date.now()}" alt="${escapeHtml(mpn + ' ' + label)}" loading="lazy" ${onerr}><span class="thumb-label">${escapeHtml(label)}</span>${refreshing ? '<span class="thumb-spinner" aria-label="re-rendering symbol"></span>' : ''}</div>`;+ if (noHover && sources && sources.length) return `<div class="thumb-src-group">${html}${srcChipHtml(mpn, kind, sources)}</div>`;+ return html; }--// Always-visible tappable source chip + popover of all source methods.-function srcCarat(mpn, kind, sources) {- if (!sources.length) return '';+function shortSrc(label) { const l = String(label || ''); if (/^kicad/i.test(l)) return 'KiCad'; if (/^adom wiki/i.test(l)) return 'Adom Wiki'; if (/ultra/i.test(l)) return 'Ultra Lib'; return l.split(/[\s,(]/)[0] || l; }+function srcChipHtml(mpn, kind, sources) { const active = activeMethod(mpn, kind, sources); const cur = sources.find(s => s.method === active) || sources.find(s => s.current) || sources[0]; const multi = sources.length > 1;- const opts = sources.map(s => {- const tsrc = s.thumb ? `${API_BASE}/thumb/${encodeURIComponent(mpn)}/${kind}${s.current ? '' : '.' + s.method}?t=${Date.now()}` : '';- const sel = s.method === active;- return `<div class="src-opt${sel ? ' chosen' : ''}" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}"- data-tooltip="${escapeHtml(s.label)} — ${sel ? 'the variant shown now.' : 'tap to make this the source shown on the tile + open it in adom-' + kind + '.'}">- ${s.thumb ? `<img class="src-opt-thumb" src="${tsrc}" loading="lazy" onerror="this.outerHTML='<span class=\\'src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}\\'></span>'">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}- <span class="src-opt-lab">${escapeHtml(s.label)}</span>${sel ? '<span class="src-cur">●</span>' : ''}- </div>`;- }).join('');- return `<button class="src-chip" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}"- data-tooltip="Source of this ${kind}: ${escapeHtml(cur.label)}.${multi ? ' ' + sources.length + ' methods — tap to switch.' : ' Tap for details.'}">- <span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(cur.label)}</span>${multi ? '<span class="src-chip-car">▾</span>' : ''}- </button>- <div class="src-popover" data-mpn="${escapeHtml(mpn)}" style="display:none">${opts}</div>`;+ return `<button class="src-chip" type="button" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-tooltip="${escapeHtml('Source of this ' + kind + ': ' + cur.label + '.' + (multi ? ' ' + sources.length + ' sources available; click to compare and switch.' : ' Click for details.'))}"><span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(shortSrc(cur.label))}</span>${multi ? icon('chevron', 10) : ''}</button>`;+}+// The 3D tile shows the chosen up-axis render; the picker (bottom-left) lists all six.+function render3dTile(c) {+ const chosen = ALL_AXES.includes(c.chosen_up_axis) ? c.chosen_up_axis : (ALL_AXES.includes(c.inferred_up_axis) ? c.inferred_up_axis : 'z');+ const ai = axis3dInfo(chosen);+ const present = chosen === 'z' ? c.has_thumb_chip3d : chosen === 'y' ? c.has_thumb_chip3d_yup : true;+ const tipText = `Open ${c.mpn} in the adom-step 3D viewer and orbit the real STEP model. Showing the ${ai.label} render` + (c.chosen_up_axis ? ' (you picked it)' : c.inferred_up_axis ? ' (auto-detected)' : '') + '.';+ const tile = renderThumb(c.mpn, ai.kind, present, '3D', thumbErr(c, '3d'), null, present ? tipText : null);+ const picker = c.has_step ? `<button class="orient-more-btn" type="button" data-mpn="${escapeHtml(c.mpn)}" aria-label="Pick the up-axis" data-tooltip="Pick the correct face-up orientation from all six renders. Your pick becomes the main 3D view and is saved to info.json for downstream tools.">${icon('axis', 13)}</button>` : '';+ return src3dChip(c) + tile + picker;+}+function filesMeter(c) {+ const parts = [['Datasheet', c.has_pdf], ['STEP 3D', c.has_step], ['KiCad symbol', c.has_sym], ['KiCad footprint', c.has_mod], ['Fusion .lbr', c.has_fusion_lbr]];+ const n = parts.filter(p => p[1]).length;+ const tipText = parts.map(p => (p[1] ? 'Present: ' : 'Missing: ') + p[0]).join('\n') + (n < parts.length ? '\nUse the re-fetch button on the card to fetch a missing file from a specific source.' : '');+ return `<span class="pill ${n === parts.length ? 'ok' : n === 0 ? 'unk' : 'warn'}" data-tooltip="${escapeHtml(tipText)}">${n}/${parts.length} files</span>`;+}+function validationPill(c) {+ const v = c.validation_status;+ if (v === 'golden') return `<span class="pill ok" data-tooltip="Golden: every source (ds2sf from the datasheet, the KiCad bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names and package family. Safe for downstream use.">${icon('check', 11)}Golden</span>`;+ if (v === 'golden_with_variants') { const vs = (c.validation_variants || []).map(x => x.replace(/_/g, ' ')).join(', '); return `<span class="pill ok" data-tooltip="${escapeHtml('Golden with explainable variants. Sources disagree on some axes, but every disagreement is benign: ' + vs + '. Safe for downstream; the variant list tells tools which source to prefer per axis.')}">${icon('check', 11)}Golden +${(c.validation_variants || []).length}</span>`; }+ if (v === 'divergent') return `<span class="pill warn" data-tooltip="Divergent: at least one source disagrees and the disagreement is not explained by a known variant. Use the re-validate button, or open the concur result.json for the conflict axis and suggested re-fetch sources.">${icon('warn', 11)}Divergent</span>`;+ if (v === 'unrecoverable') return `<span class="pill bad" data-tooltip="Unrecoverable: concur could not parse enough sources to run the comparison. Check the chip directory for missing or corrupt files.">${icon('x', 11)}Failed</span>`;+ return `<span class="pill unk" data-tooltip="Not validated yet. Use the re-validate button: it runs ds2sf (extracts symbol and footprint from the datasheet PDF) then concur (cross-checks against KiCad, EAGLE and Altium). About 30 to 60 seconds per chip.">Not validated</span>`;+}+function pin1Pill(c) {+ if (!(c.has_step && c.has_mod)) return '';+ const fp = c.fp_pin1_corner || null;+ if (c.pin1_signed) {+ const corner = c.pin1_corner || '?';+ const when = c.pin1_signed_at ? ' (signed ' + String(c.pin1_signed_at).slice(0, 10) + ')' : '';+ const meas = c.pin1_measure_ok === false ? '\nNote: chipsmith flagged a measurement mismatch.' : '';+ if (fp && corner !== '?' && corner !== fp) return `<span class="pill bad" data-tooltip="${escapeHtml('Pin-1 mismatch' + when + '. The 3D chip\'s pin-1 was signed off in the ' + corner + ' corner, but the footprint\'s pin-1 is in the ' + fp + ' corner. Re-align in chipsmith so the chip and footprint agree.' + meas)}">${icon('warn', 11)}Pin-1 chip ${escapeHtml(corner)} / fp ${escapeHtml(fp)}</span>`;+ return `<span class="pill accent" data-tooltip="${escapeHtml('Pin-1 aligned and signed off in chipsmith' + when + '. Pin-1 sits in the ' + corner + ' corner' + (fp ? ' (matches the footprint)' : '') + '; ' + c.mpn + '.chipsmith.step is the oriented model downstream tools use.' + meas)}">${icon('check', 11)}Pin-1 ${escapeHtml(corner)}</span>`;+ }+ const tipText = fp ? `Pin-1 not aligned yet. The footprint pin-1 is in the ${fp} corner (that is the target). Use Align pin-1 in the detail panel (opens chipsmith) to line up the 3D chip's pin-1 and sign off.` : 'Pin-1 not aligned yet. The 3D chip and footprint are both here. Use Align pin-1 in the detail panel (opens chipsmith) to line up pin-1 and sign off.';+ return `<span class="pill unk" data-tooltip="${escapeHtml(tipText)}">Pin-1 ?${fp ? ', fp ' + escapeHtml(fp) : ''}</span>`;+}+function stockInfo(c) {+ const m = (c.stock && c.stock.mouser) || null;+ if (!m) return null;+ const s = m.stock || 0, lc = (m.lifecycle || '').toLowerCase();+ const info = { stock: s, price: m.unit_price || null, lifecycle: m.lifecycle || '', cls: s >= 1000 ? 'ok' : s > 0 ? 'warn' : 'bad', label: s > 0 ? (s >= 1000 ? (s / 1000).toFixed(s >= 10000 ? 0 : 1) + 'k in stock' : s + ' in stock') : 'No stock' };+ if (/end of life|obsolete|discontinued|last.?time/.test(lc)) info.lc = { cls: 'bad', text: 'End of life', tipText: 'End of life (' + m.lifecycle + '). This chip is discontinued; do not use it in new designs.' };+ else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) info.lc = { cls: 'warn', text: 'NRND', tipText: 'Not recommended for new designs (' + m.lifecycle + '). Still available, but the manufacturer is phasing it out.' };+ else if (/new product|new release/.test(lc)) info.lc = { cls: 'ok', text: 'New', tipText: 'New product (' + m.lifecycle + '). Recently released; stock may be limited while supply ramps.' };+ return info;+}+function stockPills(c) {+ const st = stockInfo(c); if (!st) return '';+ let h = `<span class="pill ${st.cls}" data-tooltip="${escapeHtml('Mouser stock: ' + st.stock + ' units. ' + (st.stock >= 1000 ? 'Well stocked, safe for production quantities.' : st.stock > 0 ? 'Low stock; check lead time before committing.' : 'Out of stock at Mouser; check DigiKey or JLCPCB.'))}">${escapeHtml(st.label)}</span>`;+ if (st.lc) h += `<span class="pill ${st.lc.cls}" data-tooltip="${escapeHtml(st.lc.tipText)}">${escapeHtml(st.lc.text)}</span>`;+ return h; } -async function setOrientation(mpn, axis) {- try {- // Show the spinner INSTANTLY (before the round-trip) — the server regenerates- // the outline+symbol in the background, so the click returns right away and- // the user keeps working while the SYM tile spins.- markSymRefreshing(mpn);- render();- const r = await fetch(API_BASE + '/api/orientation', {- method: 'POST',- headers: {'Content-Type': 'application/json'},- body: JSON.stringify({mpn, axis}),- });- const j = await r.json().catch(() => ({}));- if (!r.ok || !j.ok) {- SYM_REFRESHING.delete(mpn);- alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));- render();- return;+function render() {+ if (!LIB) return;+ hideTip();+ const q = $id('q').value.toLowerCase(), v = $id('vendor').value, s = $id('status').value;+ const cards = $id('cards'); cards.innerHTML = '';+ const filtered = LIB.chips.filter(c => {+ if (ACTIVE_PROJECT_FILTER && PROJECTS) { const proj = PROJECTS.projects[ACTIVE_PROJECT_FILTER]; if (proj && !proj.mpns.includes(c.mpn)) return false; }+ if (q && !(c.mpn + ' ' + c.vendor + ' ' + c.package).toLowerCase().includes(q)) return false;+ if (v && c.vendor !== v) return false;+ if (s === 'full' && !c.fully_bundled) return false;+ if (s === 'stub' && c.fully_bundled) return false;+ if (s === 'no-info' && c.has_info) return false;+ if (s === 'no-pdf' && c.has_pdf) return false;+ return true;+ });+ if (!filtered.length) { cards.innerHTML = `<div class="empty-state">${LIB.chips.length ? 'No chips match these filters.' : 'No parts in the library yet.'}</div>`; return; }+ if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {+ const activeChip = chipByMpn(ACTIVE_MPN);+ if (activeChip) {+ filtered.unshift(activeChip);+ const where = ACTIVE_PROJECT_FILTER ? 'the ' + ACTIVE_PROJECT_FILTER + ' project' : 'the current filter';+ const note = document.createElement('div'); note.className = 'out-of-filter-note';+ note.innerHTML = `Showing <b>${escapeHtml(ACTIVE_MPN)}</b> because it is being fetched, although it is not in ${escapeHtml(where)}. <button class="btn small" type="button">Clear filter</button>`;+ note.querySelector('button').onclick = () => { ACTIVE_PROJECT_FILTER = null; $id('q').value = ''; $id('vendor').value = ''; $id('status').value = ''; renderProjectTabs(); render(); };+ cards.appendChild(note); }- const libR = await fetch(API_BASE + '/api/library');- LIB = await libR.json();- LAST_LIB_HASH = '';- render();- // Poll just until the re-rendered symbol lands (sym_rev bumps) — then the- // spinner self-clears. Capped so a failed regen can't poll forever.- const poll = setInterval(async () => {- if (!isSymRefreshing(mpn)) { clearInterval(poll); render(); return; }- try {- const lr = await fetch(API_BASE + '/api/library');- LIB = await lr.json();- LAST_LIB_HASH = '';- render();- } catch (e) { /* transient; keep polling */ }- }, 2500);- } catch (e) {- SYM_REFRESHING.delete(mpn);- render();- alert('Could not reach /api/orientation: ' + e.message); }+ for (const c of filtered) cards.appendChild(buildCard(c)); }--// Bring the active card into view. Called whenever the heartbeat MPN-// changes — cards take more vertical real estate now that thumbnails-// land below the meta row, so they scroll off screen easily during a-// long batch. Only scroll if the card is genuinely off-screen so we-// don't fight the user's manual scroll while they're inspecting.-function scrollActiveCardIntoView() {- const card = document.querySelector('.card.active') || (ACTIVE_MPN && document.querySelector(`.card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));- if (!card) return;- const rect = card.getBoundingClientRect();- const fullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;- if (fullyVisible) return;- card.scrollIntoView({behavior: 'smooth', block: 'center'});+function buildCard(c) {+ const card = document.createElement('div');+ card.className = 'card' + (SELECTED === c.mpn ? ' selected' : '') + (ACTIVE_MPN === c.mpn ? ' active' : '');+ card.dataset.mpn = c.mpn;+ const mpn = escapeHtml(c.mpn);+ const pkg = (c.package && c.package !== '?' && c.package !== DASH) ? c.package : '?';+ const size = c.size_bytes > 0 ? (c.size_bytes / 1024 / 1024).toFixed(1) + ' MB' : '? MB';+ const desc = c.info && (c.info.summary || c.info.description);+ const statusPill = ACTIVE_MPN === c.mpn+ ? `<span class="pill accent fetching" data-tooltip="${escapeHtml('Your AI is fetching this part now. Stage: ' + (ACTIVE_STAGE || 'starting'))}">Fetching</span>`+ : (c.fully_bundled ? '<span class="pill ok" data-tooltip="Fully bundled: STEP, KiCad symbol and footprint, Altium and the Fusion .lbr are all here, so every downstream tool (chipsmith, InstaPCB, molecules) can use it.">Full</span>'+ : '<span class="pill warn" data-tooltip="Stub: one or more core CAD files are missing (STEP, KiCad symbol or footprint, Altium, Fusion). Use the re-fetch button to fetch the missing pieces from a specific source.">Stub</span>');+ card.innerHTML = `+ <div class="mpn-row">+ <div class="mpn">${mpn}</div>+ <div class="card-actions">+ <button class="cbtn" type="button" data-action="validate" data-mpn="${mpn}" aria-label="Re-validate" data-tooltip="Re-validate this chip. Runs ds2sf (extracts symbol and footprint from the datasheet PDF) then concur (cross-checks against the KiCad, EAGLE and Altium libraries). The validation pill on this card updates when it finishes.">${icon('refresh')}</button>+ <button class="cbtn" type="button" data-action="thumbnails" data-mpn="${mpn}" aria-label="Re-render thumbnails" data-tooltip="Re-render thumbnails. Regenerates the symbol SVG, the footprint SVG and all six orientation 3D renders from the current library files. Useful after importing a new STEP or .kicad_sym.">${icon('image')}</button>+ <button class="cbtn refetch-btn" type="button" data-mpn="${mpn}" aria-label="Re-fetch a file" data-tooltip="Re-fetch one file from a specific source. Opens a menu of what to fetch and where from; the source's product page opens in pup and the download is watched for.">${icon('download')}</button>+ <button class="cbtn reveal-btn" type="button" data-reveal-mpn="${mpn}" aria-label="Reveal folder in VS Code" data-tooltip="Reveal this chip's folder in the VS Code Explorer, library/${mpn}/, so you can see every file: STEP, KiCad symbol and footprint, datasheets, info.json and provenance logs.">${icon('folder')}</button>+ <button class="cbtn zoom-btn" type="button" data-zoom-mpn="${mpn}" aria-label="Zoom this card" data-tooltip="Zoom this card into a centred overlay for close inspection. Escape, the X or the backdrop closes it. On a phone this is the way to enlarge a card.">${icon('zoom')}</button>+ </div>+ </div>+ <div class="meta">${[(c.vendor && c.vendor !== '?' && c.vendor !== DASH) ? escapeHtml(c.vendor) : null, pkg !== '?' ? escapeHtml(pkg) : null].filter(Boolean).join(' · ') || 'vendor and package not recorded'} · ${size}</div>+ ${desc ? `<div class="card-desc">${escapeHtml(desc)}</div>` : ''}+ <div class="thumbs">+ ${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', thumbErr(c, 'sym'), c.symbol_sources)}+ ${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', thumbErr(c, 'fp'), c.footprint_sources)}+ <div class="thumb-3d-group">${render3dTile(c)}</div>+ </div>+ <div class="card-foot">+ ${statusPill}${validationPill(c)}${filesMeter(c)}${pin1Pill(c)}${SETTINGS.show_stock_badges ? stockPills(c) : ''}+ <span class="mini-actions">+ ${c.has_pdf ? `<button class="pill-act" type="button" data-act="datasheet" data-mpn="${mpn}" data-tooltip="${escapeHtml('Open the datasheet PDF in the viewer. ' + c.pdf_count + ' datasheet' + (c.pdf_count > 1 ? 's' : '') + ' in the chip directory. Source: ' + fileSource(c, /\.pdf$/i).label)}">${icon('doc', 12)}Datasheet</button>` : ''}+ ${(c.has_sym || c.has_mod) ? `<button class="pill-act" type="button" data-act="lbr" data-mpn="${mpn}" data-tooltip="Build a true EAGLE .lbr (symbol plus footprint) for this part and open it in the adom-lbr app: the one EAGLE library Fusion 360, Altium and OrCAD all import directly. KiCad uses its own native files, which you already have.">${icon('pkg', 12)}.lbr</button>` : ''}+ </span>+ </div>`;+ return card; } -// Provenance carat: toggle the popover, or open a source variant.-document.addEventListener('click', (ev) => {- const chip = ev.target.closest('.src-chip');- if (chip) {- ev.stopPropagation(); ev.preventDefault();- const pop = chip.parentElement.querySelector('.src-popover');- document.querySelectorAll('.src-popover').forEach(p => { if (p !== pop) p.style.display = 'none'; });- if (pop) pop.style.display = pop.style.display === 'none' ? 'flex' : 'none';- return;- }- const opt = ev.target.closest('.src-opt');- if (opt) {- ev.stopPropagation();- const mpn = opt.dataset.mpn, kind = opt.dataset.kind, method = opt.dataset.method;- const canonical = opt.dataset.canonical === '1';- // Persist the pick so the carat + tile show this source (sticks across reloads).- SRC_SEL[mpn + '|' + kind] = method; saveSrcSel();- // Update the DOM in place: chip label, chosen highlight, main tile thumbnail.- const group = opt.closest('.thumb-src-group');- if (group) {- const label = opt.querySelector('.src-opt-lab').textContent;- const dotCls = method === 'ds2sf' ? 'ds' : 'vendor';- const chipLab = group.querySelector('.src-chip-lab'); if (chipLab) chipLab.textContent = label;- const chipDot = group.querySelector('.src-chip .src-dot'); if (chipDot) chipDot.className = 'src-dot ' + dotCls;- group.querySelectorAll('.src-opt').forEach(o => {- const on = o === opt;- o.classList.toggle('chosen', on);- let bullet = o.querySelector('.src-cur');- if (on && !bullet) { bullet = document.createElement('span'); bullet.className = 'src-cur'; bullet.textContent = '●'; o.appendChild(bullet); }- else if (!on && bullet) bullet.remove();- });- const img = group.querySelector('.thumb img');- if (img) img.src = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + (canonical ? '' : '.' + method) + '?t=' + Date.now();- }- const pop = opt.closest('.src-popover'); if (pop) pop.style.display = 'none';- // Switching the source ONLY swaps the tile thumbnail — it must NOT open the- // app (clicking the menu kept launching adom-symbol). Use the explicit- // "Open in" buttons to launch an app.- return;- }- document.querySelectorAll('.src-popover').forEach(p => { if (!p.contains(ev.target)) p.style.display = 'none'; });- // Click anywhere outside an open orientation dropdown closes it.- document.querySelectorAll('.orient-popover').forEach(p => {- if (!p.contains(ev.target) && !(ev.target.closest && ev.target.closest('.orient-more-btn'))) p.style.display = 'none';- });+// ---- Delegated card interactions: one handler serves the grid AND the zoom clone (a dead DOM copy). ----+document.addEventListener('click', ev => {+ const t = ev.target;+ let b;+ if ((b = t.closest('.cbtn[data-action]'))) { ev.stopPropagation(); runCardAction(b); return; }+ if ((b = t.closest('.refetch-btn'))) { ev.stopPropagation(); openRefetchMenu(b); return; }+ if ((b = t.closest('.reveal-btn'))) { ev.stopPropagation(); revealFolder(b.dataset.revealMpn); return; }+ if ((b = t.closest('.zoom-btn'))) { ev.stopPropagation(); const card = document.querySelector(`#cards .card[data-mpn="${CSS.escape(b.dataset.zoomMpn)}"]`); if (card) zoomCard(card); return; }+ if ((b = t.closest('.orient-more-btn'))) { ev.stopPropagation(); openOrientMenu(b); return; }+ if ((b = t.closest('.src-chip[data-kind]'))) { ev.stopPropagation(); openSrcMenu(b); return; }+ if ((b = t.closest('.pill-act'))) { ev.stopPropagation(); if (b.dataset.act === 'datasheet') openThumb(b.dataset.mpn, 'datasheet'); else openThumb(b.dataset.mpn, 'lbr'); return; }+ if ((b = t.closest('.thumb[data-thumb-kind]'))) { ev.stopPropagation(); if (b.dataset.thumbKind === 'symbol' || b.dataset.thumbKind === 'footprint') return; openThumb(b.dataset.thumbMpn, b.dataset.thumbKind); return; }+ if ((b = t.closest('.openbtn'))) { ev.stopPropagation(); onOpenBtn(b); return; }+ if ((b = t.closest('.sendbtn'))) { ev.stopPropagation(); onSendBtn(b); return; }+ const card = t.closest('.card');+ if (card && card.dataset.mpn && !t.closest('.card-zoom-backdrop')) select(card.dataset.mpn); });--// Lightweight toast (bottom-center). Optional href makes it a tappable link.-let CF_TOAST_T = null;-function cfToast(msg, href) {- let t = document.getElementById('cf-toast');- if (!t) { t = document.createElement('div'); t.id = 'cf-toast'; document.body.appendChild(t); }- t.innerHTML = href ? `${msg} <a href="${href}" target="_blank" rel="noopener">open ↗</a>` : msg;- t.classList.add('show'); clearTimeout(CF_TOAST_T);- CF_TOAST_T = setTimeout(() => t.classList.remove('show'), href ? 8000 : 4200);+document.addEventListener('dblclick', ev => {+ const card = ev.target.closest('#cards .card');+ if (!card || ev.target.closest('button, .thumb, a')) return;+ zoomCard(card);+});+function runCardAction(b) {+ const action = b.dataset.action, mpn = b.dataset.mpn;+ b.disabled = true;+ cfToast((action === 'validate' ? 'Re-validating ' : 'Re-rendering thumbnails for ') + mpn + ' in the background. The card updates when it finishes.');+ fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' }).then(r => r.json())+ .then(j => console.log('[' + action + '] kicked:', j))+ .catch(e => { console.error('[' + action + '] failed:', e); cfToast('Could not start ' + action + ': ' + e.message, { type: 'err' }); })+ .finally(() => setTimeout(() => { b.disabled = false; }, 3000)); }--// The viewing context, for choosing + NAMING the open method.-function viewContext() {- const embedded = window.self !== window.top; // a Hydrogen webview is an iframe- const phone = matchMedia('(hover: none)').matches || innerWidth < 760;- return { embedded, phone };+const REFETCH_OPTIONS = [+ ['manufacturer', 'kicad_sym', 'Manufacturer', 'Schematic symbol'], ['manufacturer', 'kicad_mod', 'Manufacturer', 'PCB footprint'],+ ['snapmagic', 'kicad_sym', 'SnapMagic', 'Schematic symbol'], ['snapmagic', 'kicad_mod', 'SnapMagic', 'PCB footprint'],+ ['mouser', 'kicad_sym', 'Mouser', 'Schematic symbol'], ['digikey', 'kicad_sym', 'DigiKey', 'Schematic symbol'], ['cse_ul', 'kicad_sym', 'Ultra Librarian', 'Schematic symbol'],+ ['manufacturer', 'step', 'Manufacturer', '3D STEP model'], ['manufacturer', 'pdf', 'Manufacturer', 'Datasheet PDF'],+];+function openRefetchMenu(b) {+ const mpn = b.dataset.mpn;+ const el = openMenu(b, `<div class="fhead">Re-fetch for ${escapeHtml(mpn)}: what, and from where</div>` + REFETCH_OPTIONS.map((o, i) => `<button class="fopt" type="button" data-i="${i}">${icon('download', 14)}<span>${o[3]}</span><span class="fsub">${o[2]}</span></button>`).join(''), { width: 300 });+ el.querySelectorAll('.fopt').forEach(opt => opt.onclick = () => {+ const [source, format, sl, fl] = REFETCH_OPTIONS[+opt.dataset.i]; closeMenu();+ cfToast('Re-fetching the ' + fl.toLowerCase() + ' for ' + mpn + ' from ' + sl + '. pup opens the product page on your desktop.');+ fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) + '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source), { method: 'POST' })+ .then(r => r.json()).then(j => console.log('[refetch] kicked:', j)).catch(e => cfToast('Re-fetch failed: ' + e.message, { type: 'err' }));+ }); }--// Open a chip in an Adom app. Opens in the user's CURRENT browser via-// window.open (works on a phone, in pup, or a Hydrogen webview) — the only-// universally-correct surface — and tells the user exactly how it opened.-async function openThumb(mpn, kind, source) {- const APP = { symbol: 'adom-symbol', footprint: 'adom-footprint', lbr: 'adom-lbr',- chip3d: 'adom-3D', 'chip3d-yup': 'adom-3D', datasheet: 'the datasheet', chipsmith: 'chipsmith', ds2sf: 'adom-symbol (ds2sf)' };- const app = APP[kind] || kind;- // Pre-open a blank tab INSIDE the click gesture so the browser doesn't block- // it; navigate it once the server has the chip loaded. (chipsmith has no URL —- // it opens its own Hydrogen webview server-side — so don't pre-open for it.)- const tab = kind === 'chipsmith' ? null : window.open('', '_blank');+function openOrientMenu(b) {+ const mpn = b.dataset.mpn, c = chipByMpn(mpn) || {};+ const chosen = c.chosen_up_axis;+ const el = openMenu(b, `<div class="fhead">Which render has the chip face up? Your pick becomes the 3D tile.</div><div class="fgal">` + ALL_AXES.map(ax => {+ const ai = axis3dInfo(ax), url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + ai.kind + '?t=' + Date.now();+ const sel = chosen === ax, inf = c.inferred_up_axis === ax;+ return `<button class="gopt g3d${sel ? ' chosen' : ''}" type="button" data-axis="${ax}"><img src="${url}" alt="${escapeHtml(mpn + ' ' + ai.label)}" onerror="this.parentElement.style.display='none'"><span class="glab">${ai.label}${inf ? ' (auto-detected)' : ''}</span>${sel ? `<span class="gcur">${icon('check', 11)}current</span>` : ''}</button>`;+ }).join('') + '</div>', { width: 520 });+ el.querySelectorAll('.gopt').forEach(opt => opt.onclick = () => { closeMenu(); setOrientation(mpn, opt.dataset.axis); });+}+function openSrcMenu(b) {+ const mpn = b.dataset.mpn, kind = b.dataset.kind, c = chipByMpn(mpn) || {};+ const sources = (kind === 'symbol' ? c.symbol_sources : c.footprint_sources) || [];+ if (!sources.length) return;+ const active = activeMethod(mpn, kind, sources);+ const el = openMenu(b, `<div class="fhead">Sources for the ${kind}: pick the one the tile should show</div><div class="fgal">` + sources.map(s => {+ const tsrc = s.thumb ? `${API_BASE}/thumb/${encodeURIComponent(mpn)}/${kind}${s.current ? '' : '.' + s.method}?t=${Date.now()}` : '';+ const sel = s.method === active;+ return `<button class="gopt${sel ? ' chosen' : ''}" type="button" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}">${tsrc ? `<img src="${tsrc}" alt="${escapeHtml(s.label)}" loading="lazy">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}<span class="glab">${escapeHtml(s.label)}</span>${sel ? `<span class="gcur">${icon('check', 11)}shown now</span>` : ''}</button>`;+ }).join('') + '</div>', { width: Math.min(520, sources.length * 170 + 20) });+ el.querySelectorAll('.gopt').forEach(opt => opt.onclick = () => {+ const method = opt.dataset.method;+ SRC_SEL[mpn + '|' + kind] = method; saveSrcSel(); closeMenu();+ render(); cfToast('Showing the ' + (sources.find(s => s.method === method) || {}).label + ' ' + kind + ' on the tile.', { type: 'ok' });+ });+}+async function setOrientation(mpn, axis) { try {- const r = await fetch(API_BASE + '/api/open', {- method: 'POST', headers: { 'Content-Type': 'application/json' },- body: JSON.stringify({ mpn, kind, source }),- });+ markSymRefreshing(mpn); render();+ const r = await postJson('/api/orientation', { mpn, axis }); const j = await r.json().catch(() => ({}));- if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast(`⚠ Couldn't open ${app}: ${j.error || ('HTTP ' + r.status)}`); return; }- if (kind === 'chipsmith') {- // chipsmith opens its own Hydrogen webview (on the machine running Adom- // Desktop / Hydrogen) — not a browser tab, so it won't appear on a phone.- cfToast(`Opened ${app} as a Hydrogen webview${j.desktopBridge ? ' on your Adom Desktop' : ' (needs Hydrogen — not visible on a phone)'}.`);- return;- }- const { embedded, phone } = viewContext();- const where = phone ? 'a new browser tab on your phone' : embedded ? 'a new tab from the embedded view' : 'a new browser tab';- if (tab && j.url) { tab.location.href = j.url; cfToast(`Opened ${app} in ${where} (window.open).`); }- else if (j.url) { cfToast(`${app} is ready — pop-up was blocked, tap to open:`, j.url); }- } catch (e) { if (tab) tab.close(); cfToast(`⚠ Couldn't reach the server: ${e.message}`); }-}--// Build per-file provenance lines from file_provenance JSON.-// Returns e.g. ".step from snapmagic\n.kicad_mod from snapmagic"-function fileProvenanceLines(fileProv, mpn) {- if (!fileProv || typeof fileProv !== 'object') return '';- var lines = [];- for (var filename in fileProv) {- var entry = fileProv[filename];- if (!entry) continue;- var ext = filename.replace(mpn + '.', '.').replace(mpn, '');- var src = entry.discovery_source || entry.content_origin || '?';- var origin = entry.content_origin || '';- if (origin && origin !== src) src += ' (made by ' + origin + ')';- lines.push(ext + ' from ' + src);- }- return lines.length ? '\nProvenance:\n' + lines.join('\n') : '';+ if (!r.ok || !j.ok) { SYM_REFRESHING.delete(mpn); render(); cfToast('Could not set the orientation: ' + (j.error || ('HTTP ' + r.status)), { type: 'err' }); return; }+ cfToast('Up-axis set to ' + axis3dInfo(axis).label + ' for ' + mpn + '. The symbol re-renders in the background.', { type: 'ok' });+ await loadLibrary();+ const poll = setInterval(async () => {+ if (!isSymRefreshing(mpn)) { clearInterval(poll); render(); return; }+ try { await loadLibrary(); } catch (e) {}+ }, 2500);+ } catch (e) { SYM_REFRESHING.delete(mpn); render(); cfToast('Could not reach /api/orientation: ' + e.message, { type: 'err' }); } }--// Summarize the sourcing chain from info.json into a one-line provenance string.-// Fallback when per-file provenance isn't available.-function provenanceSummary(info, fileProv, mpn) {- // Prefer per-file provenance when available- var perFile = fileProvenanceLines(fileProv, mpn);- if (perFile) return perFile;- // Fall back to chain summary- if (!info || !info.fetched_via_chain || !info.fetched_via_chain.length) return '';- var sources = info.fetched_via_chain.map(function(e) {- var s = e.source || '?';- var o = e.outcome || '';- if (o === 'attempted' || o === 'failed') s += ' (tried)';- if (o === 'success') s += ' (got it)';- return s;- });- return '\nSourced via: ' + sources.join(' > ');+function scrollActiveCardIntoView() {+ const card = document.querySelector('#cards .card.active') || (ACTIVE_MPN && document.querySelector(`#cards .card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));+ if (!card) return;+ const r = card.getBoundingClientRect();+ if (r.top >= 0 && r.bottom <= window.innerHeight) return;+ card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } -// Resolve the BEST available STEP source from every signal we have, instead of-// showing a bare "—" when the data is sitting right there. Priority:-// 1) per-file provenance for the .step (who MADE it + where we FOUND it)-// 2) the step_source field on info.json-// 3) the fetched_via_chain entry that succeeded-// Returns { label, found } — found=false means it genuinely was never recorded.-// Always-visible "where did this 3D model come from" chip (KiCad lib vs Mfr-// direct vs SnapMagic, etc.) — color is the CAD author's, NOT the source, so-// we label the source explicitly. Reads the same provenance resolveStepSource does.-function srcChipHtml(c) {- if (!c.has_step) return '';- var r = resolveStepSource(c);- var s = (r.label || '').toLowerCase(), cls = 'other', short = r.label;- if (/manufacturer|mfr|direct/.test(s)) { cls = 'mfr'; short = 'Mfr direct'; }- else if (/kicad/.test(s)) { cls = 'kicad'; short = 'KiCad lib'; }- else if (/ultralib/.test(s)) { cls = 'ul'; short = 'Ultra Librarian'; }- else if (/snap/.test(s)) { cls = 'snap'; short = 'SnapMagic'; }- else if (/mouser|cse|samacsys/.test(s)) { cls = 'mouser'; short = 'Mouser CSE'; }- else if (/digikey/.test(s)) { cls = 'dk'; short = 'DigiKey'; }- else if (/lcsc/.test(s)) { cls = 'lcsc'; short = 'LCSC'; }- else if (!r.found || /not recorded|unknown|\?/.test(s)) { cls = 'unknown'; short = 'source ?'; }- var tip = '3D model source: ' + r.label + '\n\nThe blue/grey model color is the CAD author choice, not its source. This chip is the real source.';- return '<div class="src3d-chip src3d-' + cls + '" data-tooltip="' + escapeHtml(tip) + '">' +- '<span class="src3d-dot"></span>' + escapeHtml(short) + '</div>';+// ---- Card zoom: a clone animates from the grid position to the centre; delegated handlers keep it live. ----+let ZOOM_DISMISS = null;+function dismissZoom() { if (ZOOM_DISMISS) ZOOM_DISMISS(); }+function autoZoomIfEnabled(mpn) {+ if (!SETTINGS.auto_zoom) return;+ const card = document.querySelector(`#cards .card[data-mpn="${CSS.escape(mpn)}"]`);+ if (card) setTimeout(() => zoomCard(card), 100); }--// Resolve the BEST recorded source for the file(s) matching `extRe`, the SAME-// way for every capsule: per-file provenance (who made it / where found) →-// optional `fallbackField` on the chip → the sourcing-chain entry that-// succeeded. Returns { label, found }; found=false = never recorded.-function fileSource(c, extRe, fallbackField, opts) {- opts = opts || {};- var fp = c.file_provenance, info = c.info || {};- function siteOf(url) { try { return (new URL(url)).hostname.replace(/^www\./, ''); } catch (e) { return ''; } }- var MFR = function (s) { return s === 'manufacturer' || s === 'mfr' || s === 'vendor'; };- var KIC = function (s) { return /^kicad(-library|_community)?$/.test(s); };- // Aggregators / CAD libraries — NEVER "the manufacturer". A datasheet found on- // one of these did NOT come from the maker's own site.- var AGG = function (s) { return KIC(s) || ['cse', 'componentsearch', 'cse_ul', 'samacsys',- 'snapeda', 'snapmagic', 'ultralibrarian', 'ul', 'mouser', 'digikey', 'arrow', 'lcsc'].indexOf(s) > -1; };- function labelFor(e) {- var made = (e.content_origin || '').toLowerCase(), found = (e.discovery_source || '').toLowerCase();- var site = siteOf(e.url || '');- if (made === 'unknown') made = '';- if (found === 'unknown') found = '';- // A datasheet counts as "from the manufacturer" only when it was DISCOVERED on- // the maker's own site — never via a CAD library / aggregator (CSE may host- // the maker's PDF, but that's not downloading it from the manufacturer).- if (opts.requireManufacturer && (AGG(found) || AGG(made))) return null;- // The Adom Wiki — a reused, Adom-vetted published bundle.- if (made === 'adom-wiki' || found === 'adom-wiki') return 'Adom Wiki' + (site ? ' · ' + site : '');- // NOTE: we intentionally do NOT surface the internal `derived` flag here.- // "derived" just means this provenance record was reconstructed after a bad- // wholesale tag overwrote it — it does NOT mean the source is a guess. The- // attribution itself (e.g. the maker's own site) is genuine, so showing- // "(derived)" only made users doubt a real manufacturer source.- if (KIC(made) || KIC(found)) return 'KiCad standard library';- // Manufacturer-direct: NAME the site from the URL — never a vague "direct site".- if (MFR(made) || MFR(found)) return site ? 'manufacturer · ' + site : 'manufacturer (site unrecorded)';- if (site && (made || found)) return (made || found) + ' · ' + site;- if (made || found) {- var lbl = made || found;- if (made && found && found !== made) lbl = made + ' (via ' + found + ')';- return lbl;- }- return site || '';+function zoomCard(cardEl) {+ const alreadyOpen = document.querySelector('.card-zoom-backdrop');+ if (alreadyOpen) { if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return; dismissZoom(); alreadyOpen.remove(); }+ const rect = cardEl.getBoundingClientRect(), phone = isPhone();+ const backdrop = document.createElement('div'); backdrop.className = 'card-zoom-backdrop'; backdrop.dataset.mpn = cardEl.dataset.mpn || '';+ const wrapper = document.createElement('div'); wrapper.className = 'card-zoomed-wrapper';+ const clone = cardEl.cloneNode(true); clone.style.width = rect.width + 'px'; clone.style.transformOrigin = 'top left';+ clone.classList.remove('selected');+ const zchip = chipByMpn(cardEl.dataset.mpn);+ if (zchip) {+ const open = [zchip.has_sym && ['symbol', 'adom-symbol'], zchip.has_mod && ['footprint', 'adom-footprint'], (zchip.has_sym || zchip.has_mod) && ['lbr', 'adom-lbr'], zchip.has_step && ['chip3d', 'adom-3D'], zchip.has_step && ['chipsmith', 'chipsmith'], (zchip.symbol_sources || []).some(s => s.method === 'ds2sf') && ['ds2sf', 'ds2sf extraction']].filter(Boolean);+ const sec = document.createElement('div'); sec.className = 'zoom-actions';+ sec.innerHTML = `<div class="zoom-sec">Open in</div><div class="openin-row" data-mpn="${escapeHtml(zchip.mpn)}">` + open.map(([k, l]) => `<button class="openbtn" type="button" data-kind="${k}">${l}</button>`).join('')+ + `</div><div class="zoom-sec">Send to</div><div class="sendto-row" data-mpn="${escapeHtml(zchip.mpn)}">` + [['kicad', 'KiCad'], ['fusion360', 'Fusion'], ['altium', 'Altium'], ['orcad', 'OrCAD']].map(([t, l]) => `<button class="sendbtn" type="button" data-target="${t}">${l}</button>`).join('') + '</div>';+ clone.appendChild(sec); }- if (fp && typeof fp === 'object') {- for (var fn in fp) {- if (!extRe.test(fn)) continue;- var l = labelFor(fp[fn] || {});- if (l) return { label: l, found: true }; // null = mis-tagged source, keep looking- }+ if (!phone) {+ const media = document.createElement('div'); media.className = 'zoom-col-media';+ const info = document.createElement('div'); info.className = 'zoom-col-info';+ Array.from(clone.children).forEach(ch => (ch.classList.contains('thumbs') ? media : info).appendChild(ch));+ clone.appendChild(media); clone.appendChild(info); clone.classList.add('zoom-fluid');+ clone.style.width = Math.min(900, Math.round(window.innerWidth * 0.82)) + 'px';+ } else backdrop.classList.add('fullscreen');+ wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)';+ const zoomMpn = cardEl.dataset.mpn;+ function syncZoomStatus() {+ const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;+ let bar = backdrop.querySelector('.zoom-status');+ if (working) { if (!bar) { bar = document.createElement('div'); bar.className = 'zoom-status'; backdrop.appendChild(bar); } bar.innerHTML = '<span class="led"></span><span></span>'; bar.lastChild.textContent = 'Working on this part: ' + ACTIVE_STAGE; }+ else if (bar) bar.remove(); }- if (fallbackField && !opts.requireManufacturer) {- var v = (c[fallbackField] || '').trim();- if (v && v !== '—' && v.toLowerCase() !== 'none') return { label: v, found: true };+ syncZoomStatus();+ const statusPoll = setInterval(syncZoomStatus, 1500);+ wrapper.appendChild(clone); backdrop.appendChild(wrapper);+ const onResize = () => fitZoom();+ function dismiss() {+ clearInterval(statusPoll); window.removeEventListener('resize', onResize); document.removeEventListener('keydown', escHandler);+ backdrop.classList.remove('visible');+ if (!phone) { wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)'; }+ setTimeout(() => backdrop.remove(), 300);+ if (ZOOM_DISMISS === dismiss) ZOOM_DISMISS = null;+ ZOOM_MPN = null; reportUi(); }- if (!opts.requireManufacturer) {- var chain = info.fetched_via_chain || [];- for (var i = 0; i < chain.length; i++) {- var o = (chain[i].outcome || '').toLowerCase();- if (o.indexOf('ok') > -1 || o.indexOf('success') > -1 || o === 'got it') return { label: chain[i].source || '?', found: true };- }+ ZOOM_DISMISS = dismiss; ZOOM_MPN = zoomMpn; reportUi();+ const xbtn = document.createElement('button'); xbtn.className = 'card-zoom-x'; xbtn.type = 'button'; xbtn.setAttribute('aria-label', 'Close'); xbtn.setAttribute('data-tooltip', 'Close the zoomed card (Escape also works).'); xbtn.innerHTML = icon('x', 20);+ xbtn.onclick = e => { e.stopPropagation(); dismiss(); };+ backdrop.appendChild(xbtn); document.body.appendChild(backdrop);+ function fitZoom() {+ if (phone) return;+ const naturalW = clone.offsetWidth || rect.width, naturalH = clone.offsetHeight || rect.height;+ const scale = Math.min((window.innerWidth * 0.94) / naturalW, (window.innerHeight * 0.94) / naturalH);+ wrapper.style.left = Math.max(8, (window.innerWidth - naturalW * scale) / 2) + 'px';+ wrapper.style.top = Math.max(8, (window.innerHeight - naturalH * scale) / 2) + 'px';+ clone.style.transition = 'transform .3s ease-out'; clone.style.transform = `scale(${scale})`; }- if (opts.requireManufacturer) return { label: 'NOT from the manufacturer yet — refetch the datasheet from the maker’s site', found: false };- return { label: 'not recorded — refetch to capture it', found: false };+ backdrop.classList.add('visible');+ requestAnimationFrame(() => requestAnimationFrame(fitZoom));+ clone.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', fitZoom, { once: true }); });+ window.addEventListener('resize', onResize);+ backdrop.onclick = ev => { if (ev.target === backdrop) dismiss(); };+ const escHandler = ev => { if (ev.key === 'Escape') dismiss(); };+ document.addEventListener('keydown', escHandler); }-function resolveStepSource(c) { return fileSource(c, /\.step$/i, 'step_source'); } -// One badge per EDA tool: green if both halves present, amber if partial,-// grey "?" if missing. Fusion uses one .lbr containing both sym+mod, so-// callers pass the same flag twice for that tool.-function badgeForEda(label, hasSym, hasMod, info, fileProv, mpn) {- const fullName = {KICAD:'KiCad',ALTIUM:'Altium',FUSION:'Fusion 360'}[label]||label;- const extRe = {KICAD:/\.kicad_(sym|mod)$/i, ALTIUM:/\.(SchLib|PcbLib|IntLib)$/i, FUSION:/\.lbr$/i}[label] || /$.^/;- // Lead with WHERE these library files came from (same resolver as STEP/PDF).- const src = (hasSym || hasMod) ? 'Source: ' + fileSource({file_provenance: fileProv, info: info}, extRe).label + '\n\n' : '';- if (hasSym && hasMod) {- return `<span class="b ${label.toLowerCase()}" data-tooltip="${src}${fullName} schematic symbol + PCB footprint both present — a complete ${fullName} library, ready for import into your EDA tool.">${label}</span>`;- }- if (hasSym || hasMod) {- const has = hasSym ? 'schematic symbol' : 'PCB footprint';- const miss = hasSym ? 'PCB footprint' : 'schematic symbol';- return `<span class="b partial-eda" data-tooltip="${src}${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.">${label} ${hasSym?'sym':'fp'}</span>`;- }- return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.">${label} ?</span>`;+// ---- Detail aside ----+const CANONICAL = [['manufacturer', 'Manufacturer'], ['package', 'Package'], ['family', 'Family'], ['description', 'Description'], ['step_source', 'STEP source'], ['step_authoring', 'STEP authoring'], ['step_url', 'STEP URL'], ['step_note', 'STEP note'], ['datasheet_source', 'Datasheet source'], ['datasheet_url', 'Datasheet URL'], ['datasheet_note', 'Datasheet note'], ['sym_source', 'Symbol source'], ['sym_url', 'Symbol URL'], ['mod_source', 'Footprint source'], ['mod_url', 'Footprint URL'], ['ul_deeplink_source', 'UL deep-link source'], ['ul_deeplink_url', 'UL deep-link URL'], ['ul_deeplink_note', 'UL deep-link note'], ['ul_zip', 'UL zip'], ['fetched_at', 'Fetched at'], ['verified_by', 'Verified by']];+function edaPill(label, hasSym, hasMod, c) {+ const extRe = { KiCad: /\.kicad_(sym|mod)$/i, Altium: /\.(SchLib|PcbLib|IntLib)$/i, 'Fusion 360': /\.lbr$/i }[label] || /$.^/;+ const src = (hasSym || hasMod) ? 'Source: ' + fileSource(c, extRe).label + '\n' : '';+ if (hasSym && hasMod) return `<span class="pill ok" data-tooltip="${escapeHtml(src + label + ' schematic symbol and PCB footprint are both present: a complete ' + label + ' library, ready to import.')}">${escapeHtml(label)}</span>`;+ if (hasSym || hasMod) return `<span class="pill warn" data-tooltip="${escapeHtml(src + label + ': has the ' + (hasSym ? 'schematic symbol' : 'PCB footprint') + ' but is missing the ' + (hasSym ? 'PCB footprint' : 'schematic symbol') + '. Use the re-fetch button to get the missing piece.')}">${escapeHtml(label)} ${hasSym ? 'sym' : 'fp'}</span>`;+ return `<span class="pill unk" data-tooltip="${escapeHtml(label + ' schematic symbol and PCB footprint are both missing. No ' + label + ' library files fetched yet.')}">${escapeHtml(label)} ?</span>`; }--async function select(mpn) {- SELECTED = mpn;- updateSubject();- document.querySelectorAll('.card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));+async function select(mpn, silent) {+ const changed = SELECTED !== mpn;+ SELECTED = mpn; updateSubject(); reportUi();+ document.querySelectorAll('#cards .card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));+ const aside = $id('detail'); const r = await fetch(API_BASE + '/api/chip/' + encodeURIComponent(mpn));- if (!r.ok) {- document.getElementById('detail').innerHTML = '<div class="empty-state">failed to load</div>';- return;- }+ if (!r.ok) { aside.classList.remove('empty'); aside.innerHTML = '<div class="empty-state">Could not load ' + escapeHtml(mpn) + ' (HTTP ' + r.status + ').</div>'; return; } const c = await r.json();- const aside = document.getElementById('detail');- aside.classList.remove('empty');-- // Render every canonical info.json field. "?" placeholder when unknown so- // the user sees explicitly what we don't have, not a missing row.- const CANONICAL = [- ['manufacturer', 'Manufacturer'],- ['package', 'Package'],- ['family', 'Family'],- ['description', 'Description'],- ['step_source', 'STEP source'],- ['step_authoring', 'STEP authoring'],- ['step_url', 'STEP URL'],- ['step_note', 'STEP note'],- ['datasheet_source','Datasheet source'],- ['datasheet_url', 'Datasheet URL'],- ['datasheet_note', 'Datasheet note'],- ['sym_source', 'Symbol source'],- ['sym_url', 'Symbol URL'],- ['mod_source', 'Footprint source'],- ['mod_url', 'Footprint URL'],- ['ul_deeplink_source', 'UL deep-link source'],- ['ul_deeplink_url', 'UL deep-link URL'],- ['ul_deeplink_note', 'UL deep-link note'],- ['ul_zip', 'UL zip'],- ['fetched_at', 'Fetched at'],- ['verified_by', 'Verified by'],- ];+ aside.classList.remove('empty'); aside.classList.add('open'); const info = c.info || {}; const fieldRows = CANONICAL.map(([k, label]) => {- let v = info[k];- if (v === undefined || v === null || v === '') v = '?';- const isQ = (v === '?');+ let v = info[k]; if (v === undefined || v === null || v === '' || v === DASH) v = '?'; const isUrl = typeof v === 'string' && /^https?:\/\//.test(v);- const valHtml = isUrl- ? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>`- : escapeHtml(String(v));- return `<div class="field ${isQ?'unknown':''}">- <span class="field-key">${escapeHtml(label)}</span>- <span class="field-val">${valHtml}</span>- </div>`;+ return `<div class="field${v === '?' ? ' unknown' : ''}"><span class="field-key">${escapeHtml(label)}</span><span class="field-val">${isUrl ? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>` : escapeHtml(String(v))}</span></div>`; }).join('');-+ const st = stockInfo(c); const tab = DETAIL_TAB || 'actions';+ const stepSrc = resolveStepSource(c); aside.innerHTML = `- <h2>${escapeHtml(c.mpn)}</h2>- <div class="vendor">${escapeHtml(c.vendor || '?')} · ${escapeHtml(c.package || '?')}${c.family && c.family !== '?' ? ' · ' + escapeHtml(c.family) : ''}</div>-+ <div class="aside-head"><h2>${escapeHtml(c.mpn)}</h2><button class="iconbtn aside-close" type="button" aria-label="Close details">${icon('x', 18)}</button></div>+ <div class="vendor">${[(c.vendor && c.vendor !== '?' && c.vendor !== DASH) ? escapeHtml(c.vendor) : null, (c.package && c.package !== '?' && c.package !== DASH) ? escapeHtml(c.package) : null, (c.family && c.family !== '?' && c.family !== DASH) ? escapeHtml(c.family) : null].filter(Boolean).join(' · ') || 'vendor and package not recorded'}</div> <div class="dtabs" role="tablist">- <button class="dtab${tab==='actions'?' on':''}" data-tab="actions">Actions</button>- <button class="dtab${tab==='details'?' on':''}" data-tab="details">Details</button>- <button class="dtab${tab==='files'?' on':''}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button>+ <button class="dtab${tab === 'actions' ? ' on' : ''}" role="tab" aria-selected="${tab === 'actions'}" data-tab="actions">Actions</button>+ <button class="dtab${tab === 'details' ? ' on' : ''}" role="tab" aria-selected="${tab === 'details'}" data-tab="details">Details</button>+ <button class="dtab${tab === 'files' ? ' on' : ''}" role="tab" aria-selected="${tab === 'files'}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button> </div>-- <div class="dpanel${tab==='actions'?' on':''}" data-panel="actions">- <div class="section">- <div class="section-title">Open in</div>+ <div class="dpanel${tab === 'actions' ? ' on' : ''}" data-panel="actions" role="tabpanel">+ <div class="section"><div class="section-title">Open in</div> <div class="openin-row" data-mpn="${escapeHtml(c.mpn)}">- ${c.has_sym ? `<button class="openbtn" data-kind="symbol" data-tooltip="Open ${escapeHtml(c.mpn)}'s schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}- ${c.has_mod ? `<button class="openbtn" data-kind="footprint" data-tooltip="Open ${escapeHtml(c.mpn)}'s PCB footprint in the live adom-footprint app (layers + InstaPCB solder-paste dots).">adom-footprint</button>` : ''}- ${(c.has_sym || c.has_mod) ? `<button class="openbtn" data-kind="lbr" data-tooltip="Build + open a true EAGLE .lbr (symbol + footprint) in adom-lbr.">adom-lbr</button>` : ''}- ${c.has_step ? `<button class="openbtn" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}- ${(c.has_step && c.has_mod) ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Align pin-1 in chipsmith.\nOpens the 3D STEP + footprint together so you can line up the chip's pin-1 with the footprint's pin-1, then sign off. chip-fetcher reads the result back: the Pin-1 ${c.pin1_signed ? '✓' : '?'} badge + a baked ${escapeHtml(c.mpn)}.chipsmith.step.">${c.pin1_signed ? 'Pin-1 ✓' : 'Align pin-1'}</button>` : (c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith (STEP-native detection + sign-off). A footprint (.kicad_mod) is needed to align pin-1.">chipsmith</button>` : '')}- ${(c.symbol_sources||[]).some(s=>s.method==='ds2sf') ? `<button class="openbtn" data-kind="ds2sf" data-tooltip="View ds2sf's datasheet-extracted symbol (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}- </div>- </div>- <div class="section">- <div class="section-title">Send to</div>+ ${c.has_sym ? `<button class="openbtn" type="button" data-kind="symbol" data-tooltip="Open the schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}+ ${c.has_mod ? `<button class="openbtn" type="button" data-kind="footprint" data-tooltip="Open the PCB footprint in the live adom-footprint app (layers plus InstaPCB solder-paste dots).">adom-footprint</button>` : ''}+ ${(c.has_sym || c.has_mod) ? `<button class="openbtn" type="button" data-kind="lbr" data-tooltip="Build and open a true EAGLE .lbr (symbol plus footprint) in adom-lbr.">adom-lbr</button>` : ''}+ ${c.has_step ? `<button class="openbtn" type="button" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}+ ${(c.has_step && c.has_mod) ? `<button class="openbtn" type="button" data-kind="chipsmith" data-tooltip="${escapeHtml('Align pin-1 in chipsmith. Opens the 3D STEP and the footprint together so you can line up the chip\'s pin-1 with the footprint\'s pin-1, then sign off. Chip fetcher reads the result back as the Pin-1 pill and a baked ' + c.mpn + '.chipsmith.step.')}">${c.pin1_signed ? 'Pin-1 signed' : 'Align pin-1'}</button>` : (c.has_step ? `<button class="openbtn" type="button" data-kind="chipsmith" data-tooltip="Open in chipsmith (STEP-native pin detection and sign-off). A footprint is needed before pin-1 can be aligned.">chipsmith</button>` : '')}+ ${(c.symbol_sources || []).some(s => s.method === 'ds2sf') ? `<button class="openbtn" type="button" data-kind="ds2sf" data-tooltip="View the symbol ds2sf extracted from the datasheet (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}+ </div></div>+ <div class="section"><div class="section-title">Send to</div> <div class="sendto-row" data-mpn="${escapeHtml(c.mpn)}">- <button class="sendbtn" data-target="kicad" data-tooltip="Install the native KiCad symbol + footprint into your KiCad libraries via Adom Desktop.">KiCad</button>- <button class="sendbtn" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>- <button class="sendbtn" data-target="altium" data-tooltip="Build the EAGLE .lbr + import steps (native Altium exporter on the roadmap).">Altium</button>- <button class="sendbtn" data-target="orcad" data-tooltip="Build the EAGLE .lbr (native OrCAD exporter on the roadmap).">OrCAD</button>- </div>- </div>- <div class="section">- <div class="section-title">Status</div>- <div class="badges">- <span class="b ${c.fully_bundled?'full':'stub'}">${c.fully_bundled?'FULL':'STUB'}</span>- <span class="b ${c.has_step?'step':'miss'}">STEP${c.has_step?'':' ?'}</span>- ${badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn)}- ${badgeForEda('ALTIUM', c.has_altium, c.has_altium, c.info, c.file_provenance, c.mpn)}- ${badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn)}- <span class="b ${c.pdf_count>0?'pdf':'miss'}">${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>- <span class="b src ${srcTier(c.step_source)}">source: ${escapeHtml(srcShort(c.step_source))}</span>- </div>- </div>+ <button class="sendbtn" type="button" data-target="kicad" data-tooltip="Install the native KiCad symbol and footprint into your KiCad libraries through Adom Desktop.">KiCad</button>+ <button class="sendbtn" type="button" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>+ <button class="sendbtn" type="button" data-target="altium" data-tooltip="Build the EAGLE .lbr plus import steps (a native Altium exporter is on the roadmap).">Altium</button>+ <button class="sendbtn" type="button" data-target="orcad" data-tooltip="Build the EAGLE .lbr (a native OrCAD exporter is on the roadmap).">OrCAD</button>+ </div></div>+ <div class="section"><div class="section-title">Status</div>+ <div class="pills">+ <span class="pill ${c.fully_bundled ? 'ok' : 'warn'}">${c.fully_bundled ? 'Full' : 'Stub'}</span>+ <span class="pill ${c.has_step ? 'ok' : 'unk'}" data-tooltip="${escapeHtml(c.has_step ? 'STEP source: ' + stepSrc.label + '. The 3D STEP model is used by chipsmith for pin detection and footprint validation, and by chip-thumbnailer for the orientation renders.' : '3D STEP model missing. Needed for chipsmith validation and 3D thumbnails.')}">STEP${c.has_step ? '' : ' ?'}</span>+ ${edaPill('KiCad', c.has_sym, c.has_mod, c)}${edaPill('Altium', c.has_altium, c.has_altium, c)}${edaPill('Fusion 360', c.has_fusion_lbr, c.has_fusion_lbr, c)}+ <span class="pill ${c.pdf_count > 0 ? 'ok' : 'unk'}" data-tooltip="${escapeHtml(c.pdf_count > 0 ? 'Source: ' + fileSource(c, /\.pdf$/i).label : 'No datasheet PDF yet. Use re-fetch, Manufacturer, Datasheet PDF.')}">${c.pdf_count > 0 ? c.pdf_count : '?'} PDF${c.pdf_count === 1 ? '' : 's'}</span>+ <span class="pill ${srcTier(c.step_source) === 'unk' ? 'unk' : 'accent'}" data-tooltip="${escapeHtml(stepSrc.found ? 'STEP source: ' + stepSrc.label + '. Ladder priority: manufacturer, SnapMagic, Mouser, DigiKey, Arrow, CSE and UL, then LCSC.' : 'STEP source ' + stepSrc.label)}">source: ${escapeHtml(srcShort(c.step_source))}</span>+ </div></div>+ <div class="section"><div class="section-title">Stock and price (Mouser)</div>+ <div class="fields">+ <div class="field${st ? '' : ' unknown'}"><span class="field-key">Stock</span><span class="field-val">${st ? escapeHtml(st.label) : 'not fetched yet'}</span></div>+ <div class="field${st && st.price ? '' : ' unknown'}"><span class="field-key">Unit price</span><span class="field-val">${st && st.price ? escapeHtml(st.price) : '?'}</span></div>+ <div class="field${st && st.lifecycle ? '' : ' unknown'}"><span class="field-key">Lifecycle</span><span class="field-val">${st && st.lifecycle ? escapeHtml(st.lifecycle) : '?'}</span></div>+ </div></div> </div>-- <div class="dpanel${tab==='details'?' on':''}" data-panel="details">- <div class="fields">${fieldRows}</div>- </div>-- <div class="dpanel${tab==='files'?' on':''}" data-panel="files">- <ul class="filelist">- ${c.files.map(f => {- const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f);- return `<li><a href="${url}" target="_blank">${escapeHtml(f)}</a></li>`;- }).join('')}- </ul>- </div>- `;- aside.querySelectorAll('.dtab').forEach(t => t.onclick = () => {- DETAIL_TAB = t.dataset.tab;- aside.querySelectorAll('.dtab').forEach(x => x.classList.toggle('on', x === t));- aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB));- });+ <div class="dpanel${tab === 'details' ? ' on' : ''}" data-panel="details" role="tabpanel"><div class="fields">${fieldRows}</div></div>+ <div class="dpanel${tab === 'files' ? ' on' : ''}" data-panel="files" role="tabpanel"><ul class="filelist">${c.files.map(f => `<li><a href="${API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f)}" target="_blank" rel="noopener">${escapeHtml(f)}</a></li>`).join('')}</ul></div>`;+ const tabs = [...aside.querySelectorAll('.dtab')];+ const activate = t => { DETAIL_TAB = t.dataset.tab; tabs.forEach(x => { x.classList.toggle('on', x === t); x.setAttribute('aria-selected', String(x === t)); }); aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB)); };+ tabs.forEach((t, i) => { t.onclick = () => activate(t); t.onkeydown = e => { if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { const n = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]; n.focus(); activate(n); } }; });+ aside.querySelector('.aside-close').onclick = () => { aside.classList.remove('open'); };+ if (changed && !silent && isPhone()) aside.classList.add('open'); } -document.getElementById('q').addEventListener('input', render);-document.getElementById('vendor').addEventListener('change', render);-document.getElementById('status').addEventListener('change', render);--// ---------------- open a chip in a specific Adom tool -----------------// Delegated on document so it works in the detail panel AND the zoom overlay.-document.addEventListener('click', (ev) => {- const b = ev.target.closest('.openbtn');- if (!b) return;+function onOpenBtn(b) { const mpn = b.closest('.openin-row').dataset.mpn, kind = b.dataset.kind;- const prev = b.textContent; b.disabled = true; b.textContent = 'Opening…';+ const prev = b.textContent; b.disabled = true; b.textContent = 'Opening'; setTimeout(() => { b.disabled = false; b.textContent = prev; }, 2500);- // ds2sf has no viewer of its own — open its extracted symbol on the ds2sf source.- if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf');- else openThumb(mpn, kind);-});--// ---------------- send to desktop EDA tools -----------------// Delegated on document so it works in the detail panel AND the zoom overlay.-document.addEventListener('click', async (ev) => {- const b = ev.target.closest('.sendbtn');- if (!b) return;- const row = b.closest('.sendto-row');- const mpn = row.dataset.mpn, target = b.dataset.target, label = b.textContent;- const all = [...row.querySelectorAll('.sendbtn')];- all.forEach(x => x.disabled = true);- const prev = b.textContent; b.textContent = 'Sending…';+ if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf'); else openThumb(mpn, kind);+}+async function onSendBtn(b) {+ const row = b.closest('.sendto-row'), mpn = row.dataset.mpn, target = b.dataset.target, label = b.textContent;+ const all = [...row.querySelectorAll('.sendbtn')]; all.forEach(x => x.disabled = true);+ const prev = b.textContent; b.textContent = 'Sending'; try {- const r = await fetch(API_BASE + '/api/deliver', {- method: 'POST', headers: {'Content-Type': 'application/json'},- body: JSON.stringify({mpn, target}),- });- const j = await r.json().catch(() => ({}));+ const j = await (await postJson('/api/deliver', { mpn, target })).json().catch(() => ({})); if (j.needsBridge) showBridgeModal(j);- else if (j.ok) b.textContent = '✓ ' + label;- else alert((j.message || j.error || 'Could not send to ' + label));- } catch (e) { alert('Send failed: ' + e.message); }+ else if (j.ok) { b.textContent = 'Sent'; cfToast('Sent ' + mpn + ' to ' + label + '.', { type: 'ok' }); }+ else cfToast(j.message || j.error || ('Could not send to ' + label), { type: 'err' });+ } catch (e) { cfToast('Send failed: ' + e.message, { type: 'err' }); } finally { setTimeout(() => { all.forEach(x => x.disabled = false); b.textContent = prev; }, 2500); }-});+} function showBridgeModal(j) {- let m = document.getElementById('bridgeModal');- if (!m) { m = document.createElement('div'); m.id = 'bridgeModal'; document.body.appendChild(m); }- const dl = j.assetUrl- ? `<a class="bm-go" href="${API_BASE + j.assetUrl}" download>⬇ Download the .lbr</a>` : '';- const hint = j.importHint ? `<div class="bm-hint">${j.importHint}</div>` : '';- const icon = j.exportWorks ? '📦' : '🌉';- m.innerHTML = '<div class="bm-card"><div class="bm-icon">' + icon + '</div>'- + '<div class="bm-title">' + (j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge')) + '</div>'- + '<div class="bm-body">' + (j.message || '') + '</div>'- + hint- + '<div class="bm-actions">' + dl- + '<a class="bm-contribute" href="' + (j.contribute || 'https://wiki.adom.inc') + '" target="_blank" rel="noopener">Build the auto-import bridge — contribute on the wiki →</a>'- + '<button class="bm-dismiss">Close</button></div></div>';- m.classList.add('show');- m.querySelector('.bm-dismiss').onclick = () => m.classList.remove('show');- m.onclick = e => { if (e.target === m) m.classList.remove('show'); };+ const m = $id('bridgeModal'), card = $id('bmCard');+ card.innerHTML = `<div class="bm-icon">${icon(j.exportWorks ? 'pkg' : 'bridge', 24)}</div><div class="bm-title" id="bmTitle"></div><div class="bm-body"></div>`+ + (j.importHint ? `<div class="bm-hint"></div>` : '') + `<div class="bm-actions">`+ + (j.assetUrl ? `<a class="btn primary" href="${escapeHtml(API_BASE + j.assetUrl)}" download>${icon('download')}Download the .lbr</a>` : '')+ + `<a class="btn" href="${escapeHtml(j.contribute || 'https://wiki.adom.inc')}" target="_blank" rel="noopener">${icon('ext')}Build the auto-import bridge: contribute on the wiki</a>`+ + `<button class="btn bm-dismiss" type="button">Close</button></div>`;+ card.querySelector('.bm-title').textContent = j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge');+ card.querySelector('.bm-body').textContent = j.message || '';+ if (j.importHint) card.querySelector('.bm-hint').textContent = j.importHint;+ card.querySelector('.bm-dismiss').onclick = () => m.close();+ m.onclick = e => { if (e.target !== m) return; const r = m.getBoundingClientRect(); if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) m.close(); };+ if (!m.open) m.showModal();+}+function viewContext() { return { embedded: window.self !== window.top, phone: isPhone() }; }+async function openThumb(mpn, kind, source) {+ const APP = { symbol: 'adom-symbol', footprint: 'adom-footprint', lbr: 'adom-lbr', chip3d: 'adom-3D', 'chip3d-yup': 'adom-3D', datasheet: 'the datasheet', chipsmith: 'chipsmith', ds2sf: 'adom-symbol (ds2sf)' };+ const app = APP[kind] || (kind.startsWith('chip3d') ? 'adom-3D' : kind);+ const tab = kind === 'chipsmith' ? null : window.open('', '_blank');+ try {+ const r = await postJson('/api/open', { mpn, kind: kind.startsWith('chip3d') ? 'chip3d' : kind, source });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast('Could not open ' + app + ': ' + (j.error || ('HTTP ' + r.status)), { type: 'err' }); return; }+ if (kind === 'chipsmith') { cfToast('Opened ' + app + ' as a Hydrogen webview' + (j.desktopBridge ? ' on your Adom Desktop.' : ' (needs Hydrogen, so it will not appear on a phone).'), { type: 'ok' }); return; }+ const { embedded, phone } = viewContext();+ const where = phone ? 'a new tab on your phone' : embedded ? 'a new tab from the embedded view' : 'a new browser tab';+ if (tab && j.url) { tab.location.href = j.url; cfToast('Opened ' + app + ' in ' + where + '.', { type: 'ok' }); }+ else if (j.url) cfToast(app + ' is ready, but the pop-up was blocked.', { href: j.url, hrefLabel: 'Open it', ttl: 8000 });+ } catch (e) { if (tab) tab.close(); cfToast('Could not reach the server: ' + e.message, { type: 'err' }); } }--// ---------------- reveal folder in VS Code Explorer ---------------- async function revealFolder(mpn) { try {- const r = await fetch(API_BASE + '/api/reveal', {- method: 'POST',- headers: {'Content-Type': 'application/json'},- body: JSON.stringify({mpn: mpn || ''}),- });+ const r = await postJson('/api/reveal', { mpn: mpn || '' }); const j = await r.json().catch(() => ({}));- if (!r.ok || !j.ok) {- console.warn('reveal failed', j);- alert('Could not reveal folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)));- }- } catch (e) {- console.warn('reveal request failed', e);- alert('Could not reach /api/reveal: ' + e.message);- }+ if (!r.ok || !j.ok) { console.warn('reveal failed', j); cfToast('Could not reveal the folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)), { type: 'err' }); }+ else cfToast('Revealed ' + (mpn ? 'library/' + mpn : 'the library folder') + ' in the VS Code Explorer.', { type: 'ok' });+ } catch (e) { console.warn('reveal request failed', e); cfToast('Could not reach /api/reveal: ' + e.message, { type: 'err' }); } }-document.getElementById('btn-reveal-lib').addEventListener('click', () => revealFolder(''));+$id('btn-reveal-lib').addEventListener('click', () => revealFolder('')); -// ---------------- ARCHITECTURE map — first-class in-app view -----------------// Opens the tool/dependency map (served at /architecture) in a full-screen-// overlay so anyone editing chip-fetcher can see the whole structure — local-// CLIs, native libs, remote services, and the sub-apps they may need to touch.-document.getElementById('btn-arch').addEventListener('click', () => {- let ov = document.getElementById('arch-overlay');+// ---- Architecture map overlay (served at /architecture) ----+$id('btn-arch').addEventListener('click', () => {+ let ov = $id('arch-overlay'); if (!ov) {- ov = document.createElement('div');- ov.id = 'arch-overlay';- ov.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(3,6,10,.86);backdrop-filter:blur(3px);display:flex;flex-direction:column';- ov.innerHTML =- '<div style="display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid #222a33;background:#0d1117">'- + '<span style="font-weight:700;color:var(--accent,#00e6dc)">🗺 chip-fetcher — Architecture / dependency map</span>'- + '<span style="color:var(--muted,#8b949e);font-size:12px">every tool this app depends on, and what runs where</span>'- + '<a href="/architecture" target="_blank" style="margin-left:auto;color:var(--muted,#8b949e);font-size:12px;text-decoration:none">open in new tab ↗</a>'- + '<button id="arch-close" style="background:none;border:1px solid #2a323c;color:#e6edf3;border-radius:8px;padding:4px 12px;cursor:pointer;font-size:16px;line-height:1">✕</button>'- + '</div>'- + '<iframe src="/architecture" style="flex:1;width:100%;border:0;background:#0a0e14"></iframe>';+ ov = document.createElement('div'); ov.id = 'arch-overlay';+ ov.innerHTML = `<div class="arch-bar"><span class="t">Chip Fetcher architecture and dependency map</span><span class="s">every tool this app depends on, and what runs where</span><a href="${API_BASE}/architecture" target="_blank" rel="noopener">open in a new tab ${icon('ext', 12)}</a><button class="iconbtn" id="arch-close" type="button" aria-label="Close the architecture map" data-tooltip="Close the architecture map">${icon('x', 18)}</button></div><iframe src="${API_BASE}/architecture" title="Architecture map"></iframe>`; document.body.appendChild(ov);- ov.addEventListener('click', (e) => { if (e.target === ov) ov.style.display = 'none'; }); ov.querySelector('#arch-close').addEventListener('click', () => { ov.style.display = 'none'; }); } ov.style.display = 'flex'; }); -// ---------------- VALIDATION tab — brutal self-audit -----------------const REQ = [- ['datasheet_pdf','Datasheet'], ['step_3d','STEP 3D'],- ['kicad_symbol','Symbol'], ['kicad_footprint','Footprint'],- ['thumb_symbol','Sym thumb'], ['thumb_footprint','FP thumb'], ['thumb_3d','3D thumb'],-];-let validationOn = false, validationTimer = null;-function setValidationView(on) {- validationOn = on;- document.getElementById('grid-view').style.display = on ? 'none' : '';- document.getElementById('validation-view').style.display = on ? 'block' : 'none';- document.getElementById('btn-validate').style.background = on ? 'var(--accent)' : '';- document.getElementById('btn-validate').style.color = on ? '#fff' : '';- if (on) { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }- else if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }+// ---- Views: grid | validation | sources ----+function setView(view) {+ if (!['grid', 'validation', 'sources'].includes(view)) view = 'grid';+ VIEW = view;+ $id('grid-view').hidden = view !== 'grid';+ $id('validation-view').hidden = view !== 'validation';+ $id('sources-view').hidden = view !== 'sources';+ $id('appBody').classList.toggle('noaside', view !== 'grid');+ for (const [id, v] of [['btn-validate', 'validation'], ['btn-sources', 'sources']]) { const b = $id(id); b.classList.toggle('is-on', view === v); b.setAttribute('aria-pressed', String(view === v)); }+ if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }+ if (view === 'validation') { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }+ if (view === 'sources') renderSources();+ reportUi(); }+$id('btn-validate').addEventListener('click', () => setView(VIEW === 'validation' ? 'grid' : 'validation'));+$id('btn-sources').addEventListener('click', () => setView(VIEW === 'sources' ? 'grid' : 'sources'));++const REQ = [['datasheet_pdf', 'Datasheet'], ['step_3d', 'STEP 3D'], ['kicad_symbol', 'Symbol'], ['kicad_footprint', 'Footprint'], ['thumb_symbol', 'Symbol thumb'], ['thumb_footprint', 'Footprint thumb'], ['thumb_3d', '3D thumb']];+let validationTimer = null;+const chk = (ok, tipText, star) => `<span class="vchk${ok ? '' : ' bad'}"${tipText ? ` data-tooltip="${escapeHtml(tipText)}"` : ''}>${icon(ok ? 'check' : 'x', 15)}${star ? '<span class="star">*</span>' : ''}</span>`; async function renderValidation() {- const host = document.getElementById('validation-view');+ const host = $id('validation-view'); let data; try { data = await (await fetch(API_BASE + '/api/validate?v=' + Date.now())).json(); }- catch (e) { host.innerHTML = '<div class="empty-state">validation API error</div>'; return; }- const s = data.summary || {};- const allGreen = s.total > 0 && s.incomplete === 0;- const cell = (ok) => `<td style="text-align:center;font-size:16px;font-weight:700;color:${ok ? 'var(--green,#34d17e)' : '#ff5252'}">${ok ? '✓' : '✗'}</td>`;- // adom-concur cross-source consensus cell: did KiCad / EAGLE / Altium / ds2sf agree?- const consensusCell = (con) => {- if (!con) return `<td style="text-align:center;font-size:11px;color:var(--muted)">— not run</td>`;+ catch (e) { host.innerHTML = '<div class="empty-state">The validation API did not answer.</div>'; return; }+ const s = data.summary || {}, allGreen = s.total > 0 && s.incomplete === 0;+ const consensusCell = con => {+ if (!con) return '<td><span class="vnote">not run</span></td>'; const golden = /golden/i.test(con.status || '');- const col = golden ? 'var(--green,#34d17e)' : '#ff5252';- const label = golden ? (con.status === 'golden' ? 'GOLDEN' : 'GOLDEN*') : (con.status || '?').toUpperCase();- const srcs = (con.sources || []).join(' · ') || '—';- return `<td style="text-align:center;white-space:nowrap" data-tooltip="adom-concur cross-source consensus.\nCompared: ${srcs}\nMissing: ${(con.missing||[]).join(', ')||'none'}\n${con.agree} axes agree, ${con.disagree} disagree of ${con.total_axes}. GOLDEN* = agree except a naming-convention variant.">- <span style="font-weight:700;color:${col};font-size:13px">${label}</span>- <span style="font-size:10px;color:var(--muted);display:block">${con.agree}✓/${con.disagree}✗ · ${(con.sources||[]).length} src</span></td>`;+ const label = golden ? (con.status === 'golden' ? 'Golden' : 'Golden*') : (con.status || '?');+ const tipText = 'adom-concur cross-source consensus.\nCompared: ' + ((con.sources || []).join(', ') || 'none') + '\nMissing: ' + ((con.missing || []).join(', ') || 'none') + '\n' + con.agree + ' axes agree, ' + con.disagree + ' disagree of ' + con.total_axes + '. Golden* = agree except for a naming-convention variant.';+ return `<td data-tooltip="${escapeHtml(tipText)}"><span class="vcon ${golden ? 'ok' : 'bad'}">${escapeHtml(label)}</span><span class="vcon-sub">${con.agree} agree / ${con.disagree} disagree, ${(con.sources || []).length} src</span></td>`; };- // Per-source child row: which sym / fp / 3d we generated from THIS library.- // 3D with an asterisk = the library shipped no STEP, so we used the global- // chip 3D (hover explains). Normal check = the library had its own 3D.- const miniChk = (ok, why) => `<span${(!ok && why) ? ` data-tooltip="${escapeHtml(why)}" style="cursor:help;` : ` style="`}color:${ok?'var(--green,#34d17e)':'#ff5252'};font-weight:700;font-size:13px">${ok?'✓':'✗'}</span>`;- const chk3d = (s) => {- if (!s.three_d) return miniChk(false);- if (s.three_d_from_lib) return miniChk(true);- // No 3D in this library → we swapped in the global chip STEP. Name where it came from.- const where = s.step_source || 'another source';- const tip = `there was no 3D chip in this library — so we swapped in the STEP we downloaded from ${where}${s.step_url ? ' (' + s.step_url + ')' : ''}`;- return `<span data-tooltip="${escapeHtml(tip)}" style="color:var(--green,#34d17e);font-weight:700;font-size:13px;cursor:help">✓<span style="color:var(--accent)">*</span></span>`;+ const chk3d = src => {+ if (!src.three_d) return chk(false);+ if (src.three_d_from_lib) return chk(true);+ return chk(true, 'There was no 3D chip in this library, so the STEP downloaded from ' + (src.step_source || 'another source') + (src.step_url ? ' (' + src.step_url + ')' : '') + ' was swapped in.', true); };- // Download links. ORIGINAL (the raw file/archive downloaded off the internet)- // is styled as a solid accent pill ⭳; EXTRACTED files are outline pills ⬇.- const dlLinks = (c, s) => (s.files || []).map(f => {- const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file);- const tip = f.original- ? 'ORIGINAL file exactly as downloaded from the internet — inspect it yourself: ' + f.file- : 'a specific file we extracted from the download: ' + f.file;- const style = f.original- ? 'color:#06251f;background:var(--accent);font-weight:700;border:1px solid var(--accent);'- : 'color:var(--accent);border:1px solid rgba(0,184,177,.3);';- return `<a href="${url}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml(tip)}" style="${style}font-size:10.5px;text-decoration:none;border-radius:4px;padding:1px 7px;margin-left:6px;white-space:nowrap">${f.original?'⭳':'⬇'} ${escapeHtml(f.name)}</a>`;- }).join('');- const childRows = (c) => (c.sources || []).map(s => `- <tr style="background:rgba(0,184,176,.035)">- <td colspan="11" style="padding:4px 0 4px 30px;border-top:1px solid rgba(255,255,255,.04)">- <span style="color:var(--accent);font-size:11px">↳</span>- <span style="color:var(--text2);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px">${s.label}</span>- <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">sym</span> ${miniChk(s.sym, s.note)}- <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">fp</span> ${miniChk(s.fp, s.note)}- <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">3d chip</span> ${chk3d(s)}- <span style="margin-left:22px;display:inline-flex;align-items:center">${dlLinks(c, s)}</span>- </td>- </tr>`).join('');- const rows = (data.chips || []).map(c => {- const reqCells = REQ.map(([k]) => cell(!!(c.required && c.required[k]))).join('');- const nm = c.complete ? 'var(--green,#34d17e)' : '#ff5252';- return `<tr>- <td style="font-family:'JetBrains Mono',monospace;font-size:12px;white-space:nowrap;padding-right:14px;color:var(--text)">${c.mpn}</td>- ${reqCells}- <td style="text-align:center">${cell(!!(c.extra && c.extra.fusion_lbr)).replace('<td','<span').replace('</td>','</span>')}</td>- ${consensusCell(c.consensus)}- <td style="text-align:center;font-weight:700;color:${nm};white-space:nowrap">${c.complete ? '✓ COMPLETE' : '✗ INCOMPLETE'}</td>- </tr>${childRows(c)}`;- }).join('');- const head = REQ.map(([,l]) => `<th style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:0 4px">${l}</th>`).join('');+ const dlLinks = (c, src) => (src.files || []).map(f => `<a class="vdl${f.original ? ' orig' : ''}" href="${API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file)}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml((f.original ? 'Original file exactly as downloaded from the internet; inspect it yourself: ' : 'A file extracted from the download: ') + f.file)}">${icon('download', 11)}${escapeHtml(f.name)}</a>`).join('');+ const childRows = c => (c.sources || []).map(src => `<tr class="vsub"><td colspan="11"><span class="arrow">→</span><span class="slabel">${escapeHtml(src.label)}</span><span class="k">sym</span> ${chk(src.sym, src.sym ? '' : src.note)}<span class="k">fp</span> ${chk(src.fp, src.fp ? '' : src.note)}<span class="k">3D chip</span> ${chk3d(src)}<span class="vdls">${dlLinks(c, src)}</span></td></tr>`).join('');+ const rows = (data.chips || []).map(c => `<tr><td class="mpn l">${escapeHtml(c.mpn)}</td>${REQ.map(([k]) => '<td>' + chk(!!(c.required && c.required[k])) + '</td>').join('')}<td>${chk(!!(c.extra && c.extra.fusion_lbr))}</td>${consensusCell(c.consensus)}<td><span class="vverdict ${c.complete ? 'ok' : 'bad'}">${c.complete ? 'Complete' : 'Incomplete'}</span></td></tr>${childRows(c)}`).join(''); host.innerHTML = `- <div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">- <div style="font-size:18px;font-weight:700;color:var(--text)">Validation${data.project ? ' — ' + data.project : ''}</div>- <div style="font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;background:${allGreen ? 'rgba(52,209,126,.18)' : 'rgba(255,82,82,.16)'};color:${allGreen ? 'var(--green,#34d17e)' : '#ff5252'};border:1px solid ${allGreen ? 'rgba(52,209,126,.5)' : 'rgba(255,82,82,.45)'}">- ${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ' — ALL GREEN ✓' : ' — ' + (s.incomplete || 0) + ' INCOMPLETE'}- </div>- <div style="font-size:11px;color:var(--muted)">required: datasheet + STEP + symbol + footprint + 3 thumbnails. Auto-refreshes.</div>- </div>- <table style="border-collapse:collapse;width:100%">- <thead><tr>- <th style="text-align:left;font-size:11px;color:var(--muted)">MPN</th>${head}- <th style="font-size:10px;color:var(--muted)">.lbr</th>- <th style="font-size:10px;color:var(--muted)" data-tooltip="adom-concur — cross-source consensus: do the KiCad / EAGLE / Altium / ds2sf variants of this part agree?">CONSENSUS<br><span style="font-size:8px;color:var(--accent)">adom-concur</span></th>- <th style="font-size:11px;color:var(--muted)">VERDICT</th>- </tr></thead>- <tbody>${rows || '<tr><td colspan="10" style="color:var(--muted);padding:14px">no chips in scope</td></tr>'}</tbody>- </table>`;-}-document.getElementById('btn-validate').addEventListener('click', () => { if (sourcesOn) setSourcesView(false); setValidationView(!validationOn); });--// ---------------- Sourcing priority tab -----------------// The exact, code-backed order chip-fetcher searches the internet for data.-// Rendered straight from /api/sources so it can NEVER drift from real behavior.-let sourcesOn = false;-const TIER_STYLE = {- 'Manufacturer': { bg:'rgba(0,184,176,.16)', fg:'#00e6dc', bd:'rgba(0,184,176,.5)' },- 'Adom tool': { bg:'rgba(140,107,247,.18)', fg:'#b9a4ff', bd:'rgba(140,107,247,.55)' },- 'Aggregator skill':{ bg:'rgba(100,171,255,.15)', fg:'#8cc2ff', bd:'rgba(100,171,255,.5)' },- 'Last resort': { bg:'rgba(255,82,82,.14)', fg:'#ff7676', bd:'rgba(255,82,82,.45)' },-};-function setSourcesView(on) {- sourcesOn = on;- if (on && validationOn) setValidationView(false);- document.getElementById('grid-view').style.display = on ? 'none' : '';- document.getElementById('sources-view').style.display = on ? 'block' : 'none';- document.getElementById('btn-sources').style.background = on ? 'var(--accent)' : '';- document.getElementById('btn-sources').style.color = on ? '#fff' : '';- if (on) renderSources();+ <div class="vhead"><div class="vtitle">Validation${data.project ? ': ' + escapeHtml(data.project) : ''}</div>+ <div class="vsum ${allGreen ? 'ok' : 'bad'}">${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ', all green' : ', ' + (s.incomplete || 0) + ' incomplete'}</div>+ <div class="vnote">Required: datasheet, STEP, symbol, footprint and three thumbnails. Refreshes every 3 seconds.</div></div>+ <div class="vscroll"><table class="vtable"><thead><tr><th class="l">MPN</th>${REQ.map(([, l]) => `<th>${l}</th>`).join('')}<th>.lbr</th><th data-tooltip="adom-concur cross-source consensus: do the KiCad, EAGLE, Altium and ds2sf variants of this part agree?">Consensus<span class="sub">adom-concur</span></th><th>Verdict</th></tr></thead>+ <tbody>${rows || '<tr><td colspan="11" class="l"><span class="vnote">No chips in scope.</span></td></tr>'}</tbody></table></div>`; }+const TIER_CLASS = { 'Manufacturer': 't1', 'Adom tool': 't2', 'Aggregator skill': 't3', 'Last resort': 't4' }; async function renderSources() {- const host = document.getElementById('sources-view');+ const host = $id('sources-view'); let data; try { data = await (await fetch(API_BASE + '/api/sources?v=' + Date.now())).json(); }- catch (e) { host.innerHTML = '<div class="empty-state">sources API error</div>'; return; }- const badge = (tier) => {- const s = TIER_STYLE[tier] || { bg:'rgba(255,255,255,.06)', fg:'var(--muted)', bd:'var(--border)' };- return `<span style="font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:2px 7px;border-radius:5px;background:${s.bg};color:${s.fg};border:1px solid ${s.bd};white-space:nowrap">${tier}</span>`;- };- const concerns = (data.concerns || []).map(c => {- const steps = (c.steps || []).map((st, i) => `- <div style="display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)">- <div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:var(--accent);min-width:22px;text-align:right">${i + 1}.</div>- <div style="min-width:118px;display:flex;flex-direction:column;gap:5px">${badge(st.tier)}</div>- <div style="flex:1;min-width:0">- <div style="font-size:14px;font-weight:700;color:var(--text)">${st.name}</div>- <div style="font-size:12.5px;color:var(--text2);line-height:1.45;margin-top:2px">${st.detail || ''}</div>- ${st.cmd ? `<code style="display:inline-block;margin-top:5px;font-size:11.5px;color:var(--accent);background:rgba(0,184,176,.07);border:1px solid rgba(0,184,176,.2);border-radius:5px;padding:2px 7px">${st.cmd}</code>` : ''}- ${st.skill ? `<span style="font-size:10.5px;color:var(--muted);margin-left:6px">↳ ${st.skill}</span>` : ''}- </div>- </div>`).join('');- return `- <div style="margin-bottom:26px;background:var(--card,rgba(255,255,255,.02));border:1px solid var(--border);border-radius:12px;padding:16px 18px">- <div style="font-size:16px;font-weight:800;color:var(--text)">${c.concern}</div>- <div style="font-size:12.5px;color:var(--text2);margin:4px 0 6px">${c.what || ''}</div>- <div style="font-size:12px;color:var(--accent);background:rgba(0,184,176,.07);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px">▸ ${c.rule || ''}</div>- ${steps}- </div>`;- }).join('');- host.innerHTML = `- <div style="max-width:920px">- <div style="font-size:20px;font-weight:800;color:var(--text);margin-bottom:4px">Sourcing priority — how chip-fetcher searches the internet</div>- <div style="font-size:12.5px;color:var(--muted);margin-bottom:18px">The real, code-backed order (served from <code>/api/sources</code>). Three concerns, each searched top-to-bottom. <b style="color:var(--text2)">Manufacturer-first for CAD; Adom data tools (keys baked in, always work) for metadata; manual-crawl skills are the fallback; LCSC is dead last.</b></div>- ${concerns}- </div>`;+ catch (e) { host.innerHTML = '<div class="empty-state">The sources API did not answer.</div>'; return; }+ const concerns = (data.concerns || []).map(c => `<div class="src-concern"><h3>${escapeHtml(c.concern)}</h3><div class="src-what">${escapeHtml(c.what || '')}</div><div class="src-rule">${escapeHtml(c.rule || '')}</div>`+ + (c.steps || []).map((st, i) => `<div class="src-step"><div class="src-n">${i + 1}.</div><div class="src-tier"><span class="tier ${TIER_CLASS[st.tier] || ''}">${escapeHtml(st.tier)}</span></div><div class="src-body"><div class="src-name">${escapeHtml(st.name)}</div><div class="src-detail">${escapeHtml(st.detail || '')}</div>${st.cmd ? `<code class="src-cmd">${escapeHtml(st.cmd)}</code>` : ''}${st.skill ? `<span class="src-skill">skill: ${escapeHtml(st.skill)}</span>` : ''}</div></div>`).join('') + '</div>').join('');+ host.innerHTML = `<div class="srcwrap"><div class="srctitle">Sourcing order: how chip fetcher searches the internet</div><div class="srcsub">The real, code-backed order, served from <code>/api/sources</code> so it can never drift from behaviour. Three concerns, each searched top to bottom. <b>Manufacturer first for CAD; the Adom data tools (keys baked in, always work) for metadata; the manual-crawl skills are the fallback; LCSC is last.</b></div>${concerns}</div>`; }-document.getElementById('btn-sources').addEventListener('click', () => setSourcesView(!sourcesOn)); -// ---------------- credential vault -----------------const credsModal = document.getElementById('creds-modal');-const credsMsg = document.getElementById('cred-msg');-document.getElementById('btn-creds').addEventListener('click', () => {- credsModal.classList.add('open');- credsMsg.textContent = '';- loadCreds();+// ---- Settings modal (settings-layout skill): native dialog, four dismissals, optimistic bounded saves. ----+const dlg = $id('dlgSettings');+const ENUM_LABEL = { taskbar_progress: { indeterminate: 'Indeterminate bar', off: 'Off' } };+function fmtDefault(key, v) { if (ENUM_LABEL[key]) return ENUM_LABEL[key][v] || v; if (typeof v === 'boolean') return v ? 'On' : 'Off'; return String(v); }+function applySettingsToPage() {+ if (LIB) render();+}+function renderSettings() {+ const d = SETTINGS_META.defaults || SETTINGS_DEFAULTS;+ const mode = SETTINGS.taskbar_progress === 'off' ? 'off' : 'indeterminate';+ dlg.querySelectorAll('.choice').forEach(c => { const sel = c.dataset.mode === mode; c.dataset.selected = String(sel); c.querySelector('input').checked = sel; });+ $id('stTaskbar').textContent = fmtDefault('taskbar_progress', mode); $id('stTaskbar').className = 'card-status' + (mode !== 'off' ? ' ok' : '');+ const setTgl = (id, on) => { const t = $id(id); t.classList.toggle('is-on', !!on); t.setAttribute('aria-checked', String(!!on)); };+ setTgl('tglAutoSelect', SETTINGS.auto_select_active); setTgl('tglAutoZoom', SETTINGS.auto_zoom); setTgl('tglStock', SETTINGS.show_stock_badges);+ if (document.activeElement !== $id('inpStale')) $id('inpStale').value = SETTINGS.activity_stale_seconds;+ $id('stDashboard').textContent = [SETTINGS.auto_select_active ? 'following' : null, SETTINGS.auto_zoom ? 'auto-zoom' : null, SETTINGS.show_stock_badges ? 'stock pills' : null].filter(Boolean).join(', ') || 'all off';+ $id('dhTaskbar').textContent = 'Default: ' + fmtDefault('taskbar_progress', d.taskbar_progress);+ $id('dhAutoSelect').textContent = 'Default: ' + fmtDefault('auto_select_active', d.auto_select_active);+ $id('dhAutoZoom').textContent = 'Default: ' + fmtDefault('auto_zoom', d.auto_zoom);+ $id('dhStock').textContent = 'Default: ' + fmtDefault('show_stock_badges', d.show_stock_badges);+ $id('dhStale').textContent = 'Default: ' + d.activity_stale_seconds + ' seconds';+}+async function loadSettings() {+ try {+ const j = await api('/api/settings');+ if (j && j.settings) { Object.assign(SETTINGS, j.settings); SETTINGS_META = { defaults: Object.assign({}, SETTINGS_DEFAULTS, j.defaults || {}), labels: j.labels || {}, enums: j.enums || {} }; }+ renderSettings(); applySettingsToPage();+ } catch (e) { console.warn('settings load failed', e); }+}+// Optimistic: the DOM already shows the click; a failed or stalled round trip toasts and reverts.+async function saveSetting(key, value) {+ const prev = SETTINGS[key];+ SETTINGS[key] = value; renderSettings(); applySettingsToPage();+ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 5000);+ try {+ const r = await postJson('/api/settings', { [key]: value }, { signal: ctrl.signal });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) throw new Error(j.error || 'the server refused the change');+ if (j.settings) { Object.assign(SETTINGS, j.settings); renderSettings(); applySettingsToPage(); }+ cfToast((SETTINGS_META.labels[key] || key) + ': ' + fmtDefault(key, SETTINGS[key]), { type: 'ok' });+ return true;+ } catch (e) {+ SETTINGS[key] = prev; renderSettings(); applySettingsToPage();+ cfToast('Setting not saved: ' + (e.name === 'AbortError' ? 'the server did not answer within 5 s' : e.message), { type: 'err' });+ return false;+ } finally { clearTimeout(timer); }+}+function openSettings(cardId) {+ renderSettings(); renderComponents(); loadCredentials();+ dlg.style.margin = ''; dlg.style.left = ''; dlg.style.top = '';+ if (!dlg.open) dlg.showModal();+ dlg.focus();+ const body = $id('settingsBody'); body.scrollTop = 0;+ requestAnimationFrame(() => { body.scrollTop = 0; if (cardId && $id(cardId)) $id(cardId).scrollIntoView({ block: 'start' }); });+ reportUi();+}+$id('btn-settings').onclick = () => openSettings();+$id('btn-welcome-settings').onclick = () => openSettings('card-components');+$id('btnCloseSettings').onclick = () => dlg.close();+dlg.addEventListener('close', reportUi);+dlg.addEventListener('click', e => {+ if (e.target !== dlg) return;+ const r = dlg.getBoundingClientRect();+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;+ if (!inside) dlg.close(); });-document.getElementById('cred-close').addEventListener('click', () => credsModal.classList.remove('open'));-credsModal.addEventListener('click', (e) => { if (e.target === credsModal) credsModal.classList.remove('open'); });+(() => { const head = $id('settingsHead'); let drag = null;+ head.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; const r = dlg.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; head.setPointerCapture(e.pointerId); });+ head.addEventListener('pointermove', e => { if (!drag) return; const r = dlg.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - drag.dx, innerWidth - r.width)), y = Math.max(0, Math.min(e.clientY - drag.dy, innerHeight - r.height)); dlg.style.margin = '0'; dlg.style.left = x + 'px'; dlg.style.top = y + 'px'; });+ head.addEventListener('pointerup', () => { drag = null; }); head.addEventListener('pointercancel', () => { drag = null; });+})();+dlg.querySelectorAll('.choice input').forEach(inp => inp.addEventListener('change', () => saveSetting('taskbar_progress', inp.value)));+$id('tglAutoSelect').onclick = () => saveSetting('auto_select_active', !SETTINGS.auto_select_active);+$id('tglAutoZoom').onclick = () => saveSetting('auto_zoom', !SETTINGS.auto_zoom);+$id('tglStock').onclick = () => saveSetting('show_stock_badges', !SETTINGS.show_stock_badges);+$id('inpStale').addEventListener('change', () => { const v = Math.max(10, Math.min(3600, parseInt($id('inpStale').value, 10) || 90)); $id('inpStale').value = v; saveSetting('activity_stale_seconds', v); });+(() => { const b = $id('btnRestore'); let armed = false, timer = null;+ const RESTORE = 'M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8z';+ const ALERT = 'M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8';+ function disarm() { armed = false; b.classList.remove('armed'); $id('restoreLabel').textContent = 'Restore defaults'; $id('restoreIcon').setAttribute('d', RESTORE); clearTimeout(timer); }+ b.onclick = async () => {+ if (!armed) { armed = true; b.classList.add('armed'); $id('restoreLabel').textContent = 'Click again to confirm'; $id('restoreIcon').setAttribute('d', ALERT); timer = setTimeout(disarm, 4000); return; }+ disarm();+ const prev = Object.assign({}, SETTINGS);+ Object.assign(SETTINGS, SETTINGS_META.defaults || SETTINGS_DEFAULTS); renderSettings(); applySettingsToPage();+ try { const j = await (await postJson('/api/settings/reset', {})).json(); if (!j.ok) throw new Error(j.error || 'reset refused'); if (j.settings) Object.assign(SETTINGS, j.settings); renderSettings(); applySettingsToPage(); cfToast('Settings restored to their defaults.', { type: 'ok' }); }+ catch (e) { Object.assign(SETTINGS, prev); renderSettings(); applySettingsToPage(); cfToast('Could not restore defaults: ' + e.message, { type: 'err' }); }+ };+})();++// ---- Components card: one row per LED facet, Install / Update rows with a live console. ----+const JOB_FOR = { tools: 'install_tools', skill: 'install_skill' };+function renderComponents() {+ const host = $id('compRows'); if (!STATUS || !STATUS.leds) { host.innerHTML = '<div class="row-desc">Reading component status</div>'; return; }+ const keys = ledKeys(STATUS), bad = keys.filter(k => STATUS.leds[k].state !== 'ok').length;+ const st = $id('stComponents'); st.textContent = bad ? bad + ' need attention' : 'all green'; st.className = 'card-status ' + (bad ? 'warn' : 'ok');+ const existing = {}; host.querySelectorAll('.comp-row').forEach(r => existing[r.dataset.key] = r);+ host.innerHTML = '';+ keys.forEach((k, i) => {+ const l = STATUS.leds[k];+ let row = existing[k];+ if (!row) {+ row = document.createElement('div'); row.className = 'comp-row'; row.dataset.key = k;+ row.innerHTML = `<span class="led"></span><span class="lab"></span><span class="det"></span>` + (JOB_FOR[k] ? `<button class="btn small" type="button" data-job="${JOB_FOR[k]}"></button>` : '') + `<div class="console" style="flex-basis:100%"></div>`;+ const btn = row.querySelector('button[data-job]');+ if (btn) btn.onclick = () => runJob(btn.dataset.job, row.querySelector('.console'), btn);+ }+ row.querySelector('.led').className = 'led ' + l.state;+ row.querySelector('.lab').textContent = l.label || k;+ row.querySelector('.det').textContent = l.detail || '';+ row.setAttribute('data-tooltip', l._hint || '');+ const btn = row.querySelector('button[data-job]');+ if (btn && !btn.dataset.busy) btn.textContent = l.state === 'ok' ? 'Update' : 'Install';+ host.appendChild(row);+ if (i < keys.length - 1) { const d = document.createElement('div'); d.className = 'divider'; host.appendChild(d); }+ });+}+async function runJob(action, con, btn) {+ if (btn) { btn.dataset.busy = '1'; btn.disabled = true; btn.textContent = 'Running'; }+ con.classList.add('show'); con.textContent = '$ ' + action + '\n';+ let id;+ try { const j = await (await postJson('/api/jobs', { action })).json(); if (!j.ok) throw new Error(j.error || 'job refused'); id = j.id; }+ catch (e) { con.textContent += 'could not start: ' + e.message + '\n'; cfToast('Could not start ' + action + ': ' + e.message, { type: 'err' }); if (btn) { delete btn.dataset.busy; btn.disabled = false; renderComponents(); } return; }+ cfToast('Started ' + action.replace(/_/g, ' ') + '; output streams in Settings.');+ const poll = setInterval(async () => {+ try {+ const j = await api('/api/jobs/' + id);+ const lines = j.lines || [];+ con.textContent = '$ ' + action + '\n' + lines.join('\n') + (lines.length ? '\n' : '');+ if (j.state === 'done' || j.state === 'failed') {+ clearInterval(poll);+ const ok = j.state === 'done' && (j.exit_code === 0 || j.exit_code == null);+ const ex = document.createElement('span'); ex.className = 'exit ' + (ok ? 'ok' : 'bad'); ex.textContent = 'exit code ' + (j.exit_code == null ? '?' : j.exit_code) + (ok ? '' : ' (failed)'); con.appendChild(ex);+ con.scrollTop = con.scrollHeight;+ cfToast(action.replace(/_/g, ' ') + (ok ? ' finished.' : ' failed with exit code ' + j.exit_code + '.'), { type: ok ? 'ok' : 'err' });+ if (btn) { delete btn.dataset.busy; btn.disabled = false; }+ await loadStatus(true); renderComponents();+ } else con.scrollTop = con.scrollHeight;+ } catch (e) { /* keep polling */ }+ }, 700);+} -async function loadCreds() {- const list = document.getElementById('creds-list');- list.innerHTML = '<div class="info">loading…</div>';+// ---- Logins: pup's vault is the store; this page shows host + username only. ----+let CRED_ERR_SHOWN = false;+async function loadCredentials() {+ const host = $id('credList'), st = $id('stLogins'); try { const r = await fetch(API_BASE + '/api/credentials');- if (!r.ok) {- const err = await r.text();- list.innerHTML = '<div class="info">credential vault unavailable: ' + escapeHtml(err) + '</div>';+ const d = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status }));+ if (!d.ok) {+ st.textContent = 'desktop not connected'; st.className = 'card-status warn';+ if (!CRED_ERR_SHOWN) { CRED_ERR_SHOWN = true; host.innerHTML = `<div class="inline-err"><span>${escapeHtml(d.error || 'The credential vault is unavailable.')}</span>${d._hint ? `<span class="h">${escapeHtml(d._hint)}</span>` : ''}</div>`; } return; }- const d = await r.json();+ CRED_ERR_SHOWN = false; const creds = d.credentials || [];- if (creds.length === 0) {- list.innerHTML = '<div class="info">no credentials saved yet</div>';- return;- }- list.innerHTML = creds.map(c =>- `<div class="row"><div><code>${escapeHtml(c.host)}</code><div class="info">${escapeHtml(c.username)} · <span class="pw-mask" data-pw="${escapeHtml(c.password || '')}" data-tooltip="Click to reveal / hide this saved password." style="cursor:pointer; opacity:.7; user-select:none;">••••••••</span></div></div>` +- `<button data-host="${escapeHtml(c.host)}">delete</button></div>`- ).join('');- list.querySelectorAll('button').forEach(btn => {- btn.onclick = () => deleteCred(btn.dataset.host);- });- list.querySelectorAll('.pw-mask').forEach(span => {- span.onclick = () => {- const masked = span.textContent === '••••••••';- span.textContent = masked ? span.dataset.pw : '••••••••';- span.setAttribute('data-tooltip', masked ? 'Click to hide this password.' : 'Click to reveal this password.');- span.style.userSelect = masked ? 'text' : 'none';+ st.textContent = creds.length ? creds.length + ' saved in pup' : 'none saved yet'; st.className = 'card-status' + (creds.length ? ' ok' : '');+ host.innerHTML = creds.length ? '' : `<div class="row-desc">No vendor logins saved yet. Import them from your browser below, or sign in to a vendor once and pup captures it.${d._hint ? ' ' + escapeHtml(d._hint) : ''}</div>`;+ for (const c of creds) {+ const row = document.createElement('div'); row.className = 'cred-row';+ row.innerHTML = `<code></code><span class="u"></span><button class="btn small danger" type="button" data-tooltip="Remove this login from pup's vault on the desktop. The toast offers Undo for a few seconds before it is actually removed.">${icon('trash', 13)}Remove</button>`;+ row.querySelector('code').textContent = c.host; row.querySelector('.u').textContent = c.username || '';+ row.querySelector('button').onclick = () => {+ row.classList.add('pending');+ cfToast('Removing the login for ' + c.host + '.', { action: { label: 'Undo', fn: () => row.classList.remove('pending') }, onExpire: async () => {+ try { const r2 = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(c.host), { method: 'DELETE' }); if (!r2.ok) throw new Error('HTTP ' + r2.status); row.remove(); cfToast('Removed the login for ' + c.host + '.', { type: 'ok' }); loadCredentials(); }+ catch (e) { row.classList.remove('pending'); cfToast('Could not remove ' + c.host + ': ' + e.message, { type: 'err' }); }+ } }); };- });- } catch (e) {- list.innerHTML = '<div class="info">load error: ' + escapeHtml(e.message) + '</div>';- }-}--async function deleteCred(host) {- if (!confirm('Delete credentials for ' + host + '?')) return;- const r = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(host), {method: 'DELETE'});- showMsg(r.ok ? 'deleted' : 'delete failed', r.ok);- loadCreds();-}--document.getElementById('cred-save').addEventListener('click', async () => {- const host = document.getElementById('cred-host').value.trim();- const username = document.getElementById('cred-user').value.trim();- const password = document.getElementById('cred-pass').value;- if (!host || !username || !password) {- showMsg('host, username, password all required', false);- return;- }- showMsg('saving…', true);- try {- const r = await fetch(API_BASE + '/api/credentials', {- method: 'POST',- headers: {'Content-Type': 'application/json'},- body: JSON.stringify({host, username, password})- });- if (r.ok) {- showMsg('credentials saved', true);- document.getElementById('cred-pass').value = '';- loadCreds();- } else {- const err = await r.text();- showMsg('save failed: ' + err, false);+ host.appendChild(row); }- } catch (e) {- showMsg('save error: ' + e.message, false);- }-});--function showMsg(text, ok) {- credsMsg.className = 'msg ' + (ok ? 'ok' : 'err');- credsMsg.textContent = text;+ } catch (e) { st.textContent = 'unavailable'; st.className = 'card-status bad'; if (!CRED_ERR_SHOWN) { CRED_ERR_SHOWN = true; host.innerHTML = `<div class="inline-err">Could not read the vault: ${escapeHtml(e.message)}</div>`; } } }+$id('btnImportLogins').onclick = () => runJob('import_logins', $id('conImport'), $id('btnImportLogins'));+$id('btnLogin').onclick = async () => {+ const source = $id('selLogin').value, label = $id('selLogin').selectedOptions[0].textContent, b = $id('btnLogin');+ b.disabled = true;+ try { const j = await (await postJson('/api/login', { source })).json(); if (!j.ok) throw new Error(j.error || 'refused'); cfToast('Opening the ' + label + ' sign-in page in pup on your desktop.', { type: 'ok' }); }+ catch (e) { cfToast('Could not start the ' + label + ' sign-in: ' + e.message, { type: 'err' }); }+ finally { setTimeout(() => { b.disabled = false; }, 1500); }+}; -load().catch(e => {- document.getElementById('cards').innerHTML = '<div class="empty-state">load error: ' + e.message + '</div>';-});-// ---- Tooltip system per human-ui-patterns skill -----// Body-appended position:fixed div at z-index 99999, 600ms delay,-// auto-flip when near viewport edge. Every interactive element uses-// data-tooltip="..." for descriptive, newbie-friendly help text.-(function initTooltips() {- const tip = document.createElement('div');- tip.style.cssText = 'position:fixed;z-index:99999;background:#1a1f2e;color:#e6edf3;border:1px solid #3d4450;border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.4;max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;transition:opacity .15s;white-space:pre-wrap;font-family:Satoshi,-apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,0.5)';- document.body.appendChild(tip);- let timer = null;- // Touch-primary devices (phones) emulate mouseover on tap but never fire- // mouseout, so hover-tooltips stick and cover content. Disable them there;- // touch users tap elements directly. Also dismiss on any tap/scroll.- const TOUCH = window.matchMedia && window.matchMedia('(hover: none)').matches;- const hide = () => { clearTimeout(timer); tip.style.opacity = '0'; };- document.addEventListener('touchstart', hide, { passive: true });- window.addEventListener('scroll', hide, { passive: true, capture: true });- document.addEventListener('mouseover', (ev) => {- if (TOUCH) return;- const el = ev.target.closest('[data-tooltip]');- if (!el) return;- clearTimeout(timer);- timer = setTimeout(() => {- tip.textContent = el.dataset.tooltip;- tip.style.opacity = '1';- const r = el.getBoundingClientRect();- let left = r.left;- tip.style.left = '0'; tip.style.top = '0'; // reset for measurement- const tw = tip.offsetWidth, th = tip.offsetHeight;- // Prefer ABOVE the element — these chips/badges/pulldowns sit at the top- // of their card, so a tooltip below covers the thumbnail. Flip below only- // when there genuinely isn't room above (element near the viewport top).- let top = r.top - th - 6;- if (top < 8) top = r.bottom + 6;- if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;- if (left < 8) left = 8;- tip.style.left = left + 'px';- tip.style.top = top + 'px';- }, 600);- });- document.addEventListener('mouseout', (ev) => {- const el = ev.target.closest('[data-tooltip]');- if (!el) return;- clearTimeout(timer);- tip.style.opacity = '0';- });-})();+// ---- Filters ----+$id('q').addEventListener('input', render);+$id('vendor').addEventListener('change', render);+$id('status').addEventListener('change', render);+document.addEventListener('keydown', e => { if (e.key === 'Escape' && isPhone()) $id('detail').classList.remove('open'); });++// ---- Boot: first paint never waits on one slow endpoint; each source loads independently. ----+buildSay();+loadStatus();+loadSettings();+loadLibrary().catch(e => { $id('cards').innerHTML = '<div class="empty-state">Could not load the library: ' + escapeHtml(e.message) + '</div>'; });+api('/state').then(s => { if (s && s.version) { SERVER_VERSION = s.version; $id('popVersion').textContent = 'v' + s.version; $id('settingsVersion').textContent = 'v' + s.version; } }).catch(() => {});+pollActivity(); activityTimer = setInterval(pollActivity, 1000);+setInterval(() => loadStatus(), 15000);+reportUi(); </script> </body> </html>
src/validate.rs+20−21@@ -165,9 +165,9 @@ pub struct ConcurVariant { #[derive(Debug)] pub enum ValidationOutcome {- /// concur reported `golden` — every source agrees exactly.+ /// concur reported `golden`, every source agrees exactly. Golden { mpn: String },- /// concur reported `golden_with_variants` — disagreements all explained+ /// concur reported `golden_with_variants`, disagreements all explained /// by variant kinds. Variants persisted to `<MPN>-validation-status.json`. GoldenWithVariants { mpn: String,@@ -197,13 +197,13 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { .ok_or_else(|| anyhow!("chip dir has no basename: {}", dir.display()))? .to_string(); - println!("OK: validate {} — running ds2sf …", mpn);+ println!("OK: validate {}, running ds2sf …", mpn); let mut tried_pdfs: HashSet<PathBuf> = HashSet::new(); let mut ds2sf = run_ds2sf(dir, None)?; tried_pdfs.insert(ds2sf.datasheet_used.clone()); - // Phase 1 — needs_better_pdf loop+ // Phase 1, needs_better_pdf loop let mut tries = 0usize; while ds2sf.status == "needs_better_pdf" { tries += 1;@@ -222,7 +222,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { let Some(next) = next else { return Ok(ValidationOutcome::NeedsHuman { mpn,- reason: "ds2sf needs_better_pdf — no untried candidate PDFs in hints[]".into(),+ reason: "ds2sf needs_better_pdf, no untried candidate PDFs in hints[]".into(), last_concur_status: None, last_ds2sf_status: Some("needs_better_pdf".into()), });@@ -247,11 +247,11 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { }); } - // Apply update_upstream_metadata hints from ds2sf — these are+ // Apply update_upstream_metadata hints from ds2sf, these are // info.json corrections (e.g. wrong package family detected). apply_ds2sf_metadata_patches(&ds2sf); - // Phase 2 — concur loop+ // Phase 2, concur loop println!("OK: ds2sf complete; running concur …"); let mut tried_repairs: HashSet<String> = HashSet::new(); let mut used_ds2sf_preferred_symbol = false;@@ -264,8 +264,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { match r.status.as_str() { "golden" | "golden_with_variants" => {- // ul_symbol_has_stub_names is a "do not refetch" hint —- // mark the symbol source as ds2sf-preferred so downstream+ // ul_symbol_has_stub_names is a "do not refetch" hint, // mark the symbol source as ds2sf-preferred so downstream // sym_create authors a fresh symbol from ds2sf JSON. for h in &r.hints { if let ConcurHint::UlSymbolHasStubNames { .. } = h {@@ -298,7 +297,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { let key = "ds2sf_rerun".to_string(); if tried_repairs.insert(key) { let model = suggested_model.clone().unwrap_or_else(|| "claude-opus-4-7".into());- println!("OK: concur round {round} — re-running ds2sf with model {model} (reason: {})",+ println!("OK: concur round {round}, re-running ds2sf with model {model} (reason: {})", reason.as_deref().unwrap_or("?")); ds2sf = run_ds2sf_with_model(dir, &model)?; acted = true;@@ -308,7 +307,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { ConcurHint::RefetchLibrarySource { format, suggested_sources, notes, suspect, .. } => { // Try the first untried (format, source) pair. // If the refetch surface returns "not implemented"- // (current state — chip-fetcher's per-source refetch+ // (current state, chip-fetcher's per-source refetch // CLI isn't wired yet), record ALL suggested_sources // in info.json.fetched_via_chain so the human has // the priority-ordered list, then surface gracefully.@@ -316,7 +315,7 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { if !tried_repairs.contains(&key) { let pick = suggested_sources.iter().next().cloned(); if let Some(ref src) = pick {- println!("OK: concur round {round} — refetch_library_source {format} from {src} (suspect: {}, {})",+ println!("OK: concur round {round}, refetch_library_source {format} from {src} (suspect: {}, {})", suspect.as_deref().unwrap_or("?"), notes.as_deref().unwrap_or("no notes")); match attempt_refetch_library(dir, format, src) {@@ -325,13 +324,13 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { eprintln!("WARN: refetch {format}/{src} failed: {e}"); // Record the full priority-ordered list to the chain // for later manual follow-up, and stop trying refetches- // for this format — every hint here is variant of "fix+ // for this format, every hint here is variant of "fix // the kicad_sym," and they all fail the same way. for s in suggested_sources { let _ = attempt_refetch_library(dir, format, s); // appends to chain, returns Err } tried_repairs.insert(key);- // Don't break — let the OUTER for loop pick a different+ // Don't break, let the OUTER for loop pick a different // hint kind (e.g. UlSymbolHasStubNames) if present. } }@@ -347,14 +346,14 @@ pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> { if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) { eprintln!("WARN: could not mark ds2sf-preferred: {e}"); } else {- println!("OK: concur round {round} — marked symbol source ds2sf-preferred (UL stub names)");+ println!("OK: concur round {round}, marked symbol source ds2sf-preferred (UL stub names)"); used_ds2sf_preferred_symbol = true; acted = true; } break; } }- _ => { /* AcceptWithCaveats / HumanReview / SourceParseFailed / Unknown — no autonomous action */ }+ _ => { /* AcceptWithCaveats / HumanReview / SourceParseFailed / Unknown, no autonomous action */ } } } last_concur = Some(r);@@ -449,7 +448,7 @@ fn read_concur_result(chip_dir: &Path) -> Result<ConcurResult> { // ---------- side-effects ---------- -/// Apply ds2sf's update_upstream_metadata hints — patch info.json fields+/// Apply ds2sf's update_upstream_metadata hints, patch info.json fields /// where ds2sf detected the chip-fetcher hint was wrong (e.g. wrong /// package family). Best-effort; won't unwind a partial patch. fn apply_ds2sf_metadata_patches(r: &Ds2sfResult) {@@ -527,7 +526,7 @@ fn persist_validation_status(chip_dir: &Path, r: &ConcurResult) -> Result<()> { /// `chip-fetcher refetch`. `concur`'s `suggested_sources` is the /// priority-ordered list; the validate loop walks it and calls this /// function for the first untried entry. Returns Ok(()) when refetch-/// landed a new file, Err otherwise — the loop then re-runs concur and+/// landed a new file, Err otherwise, the loop then re-runs concur and /// expects the suspect axis to flip verdict. /// /// Source slug normalization: maps third-party variants ("snapeda",@@ -567,7 +566,7 @@ fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Resul }; println!("OK: invoking `adom-chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");- // Self-invoke by absolute path — name-independent across renames.+ // Self-invoke by absolute path, name-independent across renames. let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into()); let status = std::process::Command::new(exe) .args(["refetch", mpn,@@ -581,7 +580,7 @@ fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Resul Ok(()) } else { // refetch_cmd appended a failure entry; we just propagate.- Err(anyhow!("chip-fetcher refetch exited non-zero (timeout, user didn't click, or import failed) — see info.json.fetched_via_chain"))+ Err(anyhow!("chip-fetcher refetch exited non-zero (timeout, user didn't click, or import failed), see info.json.fetched_via_chain")) } } @@ -626,7 +625,7 @@ fn human_reason_from_hints(r: &ConcurResult) -> String { for (f, e) in parse_failures { parts.push(format!("parse_failed:{f}({e})")); } if parts.is_empty() {- r.summary.clone().unwrap_or_else(|| "concur divergent — no actionable hints".into())+ r.summary.clone().unwrap_or_else(|| "concur divergent, no actionable hints".into()) } else { parts.join("; ") }
src/vault.rsadded+110@@ -0,0 +1,110 @@+//! Vendor logins live in pup's OS-keychain vault (pup-credentials skill);+//! chip-fetcher keeps no password file of its own. This module is a thin+//! proxy over the `credential_*` / `pup_login` / `pup_import_*` verbs so the+//! CLI and the dashboard never spell them out.++use anyhow::Result;+use serde_json::{json, Value};++/// Vendors whose logins are worth importing from the user's real browser.+pub const DEFAULT_VENDOR_HOSTS: &[&str] = &[+ "componentsearchengine",+ "samacsys",+ "snapeda",+ "snapmagic",+ "ultralibrarian",+ "mouser",+ "digikey",+ "arrow.com",+ "ti.com",+ "st.com",+ "nxp.com",+ "microchip",+ "nordicsemi",+ "lcsc",+];++/// `{ok, credentials:[{host, username, ...}]}` with passwords never present.+pub fn list() -> Result<Value> {+ let v = crate::bridge::call("credential_list", &json!({}))?;+ if !crate::bridge::succeeded(&v) {+ return Ok(json!({+ "ok": false,+ "error": crate::bridge::error_text(&v),+ "_hint": "Vendor logins are stored by pup on your desktop. Open Adom Hydrogen there so the bridge is connected, then reload.",+ }));+ }+ let out = crate::bridge::output(&v);+ let mut creds: Vec<Value> = out+ .get("credentials")+ .and_then(|c| c.as_array())+ .cloned()+ .unwrap_or_default();+ for c in creds.iter_mut() {+ if let Some(o) = c.as_object_mut() {+ o.remove("password");+ }+ }+ Ok(json!({+ "ok": true,+ "source": "pup",+ "credentials": creds,+ "_hint": "Passwords stay in the OS keychain on the desktop. Add one with `adom-chip-fetcher login <source> --user <u> --pass <p>` or import from your browser with `adom-chip-fetcher logins --import`.",+ }))+}++pub fn set(host: &str, username: &str, password: &str) -> Result<Value> {+ let v = crate::bridge::call("credential_set", &json!({"host": host, "username": username, "password": password}))?;+ if crate::bridge::succeeded(&v) {+ Ok(json!({"ok": true, "host": host, "username": username}))+ } else {+ Ok(json!({"ok": false, "error": crate::bridge::error_text(&v)}))+ }+}++pub fn delete(host: &str) -> Result<Value> {+ let v = crate::bridge::call("credential_delete", &json!({"host": host}))?;+ if crate::bridge::succeeded(&v) {+ Ok(json!({"ok": true, "removed": true, "host": host}))+ } else {+ Ok(json!({"ok": false, "error": crate::bridge::error_text(&v)}))+ }+}++/// Read-only scan of the browser profiles on the desktop.+pub fn import_scan() -> Result<Value> {+ let v = crate::bridge::call("pup_import_scan", &json!({}))?;+ Ok(if crate::bridge::succeeded(&v) { crate::bridge::output(&v) } else { json!({"ok": false, "error": crate::bridge::error_text(&v)}) })+}++/// Import the user's saved browser logins (one consent dialog + one UAC on+/// the desktop). Counts only come back; plaintext never leaves pup.+pub fn import_browser_logins(host_filter: &[String]) -> Result<Value> {+ let hosts: Vec<&str> = if host_filter.is_empty() { DEFAULT_VENDOR_HOSTS.to_vec() } else { host_filter.iter().map(|s| s.as_str()).collect() };+ let v = crate::bridge::call("pup_import_browser_logins", &json!({"hostFilter": hosts}))?;+ Ok(if crate::bridge::succeeded(&v) { crate::bridge::output(&v) } else { json!({"ok": false, "error": crate::bridge::error_text(&v)}) })+}++/// Sign into the page currently open in `session` from the vault (or with+/// explicit credentials, saved for next time). Submit is reason-gated by pup.+pub fn login(session: &str, username: Option<&str>, password: Option<&str>, host: Option<&str>, reason: &str) -> Result<Value> {+ let mut args = json!({"sessionId": session, "submit": true, "submitReason": reason});+ if let (Some(u), Some(p)) = (username, password) {+ args["username"] = json!(u);+ args["password"] = json!(p);+ args["save"] = json!(true);+ if let Some(h) = host {+ args["host"] = json!(h);+ }+ }+ let v = crate::bridge::call("pup_login", &args)?;+ if crate::bridge::succeeded(&v) {+ Ok(crate::bridge::output(&v))+ } else {+ Ok(json!({+ "ok": false,+ "errorCode": v.get("errorCode").cloned().unwrap_or(Value::Null),+ "error": crate::bridge::error_text(&v),+ }))+ }+}
tool-map.html+13−13@@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">-<title>chip-fetcher — Tool Map</title>+<title>chip-fetcher, Tool Map</title> <style> :root{ --bg:#0a0e14; --panel:#0d1117; --ink:#e6edf3; --muted:#8b949e;@@ -78,15 +78,15 @@ </head> <body> <div class="wrap">- <h1><span>chip-fetcher</span> — tool map</h1>+ <h1><span>chip-fetcher</span>, tool map</h1> <p class="sub">What runs <em>in your container</em> vs. what we <em>call over the network</em>. Generated 2026-06-24 · updated for native Altium + EAGLE.</p> <div class="legend"> <span class="lg"><span class="dot" style="background:var(--hub)"></span>orchestrator</span>- <span class="lg"><span class="dot" style="background:var(--local)"></span>local CLI — runs in your container</span>+ <span class="lg"><span class="dot" style="background:var(--local)"></span>local CLI, runs in your container</span> <span class="lg"><span class="dot" style="background:var(--lib)"></span>local library (Rust crate, not a separate app)</span>- <span class="lg"><span class="dot" style="background:var(--remote)"></span>remote service container — we call it over HTTPS</span>- <span class="lg"><span class="dot" style="background:var(--desktop)"></span>your laptop — reached via adom-desktop bridge</span>+ <span class="lg"><span class="dot" style="background:var(--remote)"></span>remote service container, we call it over HTTPS</span>+ <span class="lg"><span class="dot" style="background:var(--desktop)"></span>your laptop, reached via adom-desktop bridge</span> </div> <!-- HUB -->@@ -104,7 +104,7 @@ <div class="tier"> <div class="tier-h"><b>Convert & render</b><em>turn each downloaded format into geometry + thumbnails</em></div> <div class="row">- <div class="node local"><div class="nm">adom-sfconvert <span class="new-pill">NEW</span></div><div class="dsc">EAGLE/Fusion .lbr → KiCad AND Altium → KiCad — <b>fully native, pure Rust</b>.</div><div class="calls">native libs: <b style="color:var(--lib)">altium-schlib</b> + <b style="color:var(--lib)">altium-pcblib</b> + eagle — <b style="color:var(--local)">no service</b></div></div>+ <div class="node local"><div class="nm">adom-sfconvert <span class="new-pill">NEW</span></div><div class="dsc">EAGLE/Fusion .lbr → KiCad AND Altium → KiCad, <b>fully native, pure Rust</b>.</div><div class="calls">native libs: <b style="color:var(--lib)">altium-schlib</b> + <b style="color:var(--lib)">altium-pcblib</b> + eagle, <b style="color:var(--local)">no service</b></div></div> <div class="node local"><div class="nm">adom-symbol</div><div class="dsc">Renders the schematic symbol (with 3D chip embossed).</div><div class="calls">→ <b>service-kicad</b></div></div> <div class="node local"><div class="nm">adom-footprint</div><div class="dsc">Renders the PCB footprint SVG.</div><div class="calls">→ <b>service-kicad</b></div></div> <div class="node local"><div class="nm">adom-ds2sf</div><div class="dsc">Extracts sym+footprint from the datasheet PDF.</div><div class="calls">local (uses <code>claude</code> + pdf tools)</div></div>@@ -118,17 +118,17 @@ <!-- LIBS --> <div class="tier">- <div class="tier-h"><b>Local libraries</b><em>Rust crates compiled in — not separate apps</em></div>+ <div class="tier-h"><b>Local libraries</b><em>Rust crates compiled in, not separate apps</em></div> <div class="row"> <div class="node lib"><div class="nm">altium-pcblib <span class="tag t-lib">lib</span></div><div class="dsc"><b>The Altium reader you built.</b> Parses .PcbLib/.IntLib binary → KiCad pad JSON + SVG. 100% local, no network.</div></div>- <div class="node lib"><div class="nm">altium-schlib <span class="new-pill">NEW</span> <span class="tag t-lib">lib</span></div><div class="dsc"><b>Native Altium SYMBOL reader</b> — parses .SchLib binary → KiCad pin JSON + SVG. 100% local, validated 28/28 vs KiCad.</div></div>+ <div class="node lib"><div class="nm">altium-schlib <span class="new-pill">NEW</span> <span class="tag t-lib">lib</span></div><div class="dsc"><b>Native Altium SYMBOL reader</b>, parses .SchLib binary → KiCad pin JSON + SVG. 100% local, validated 28/28 vs KiCad.</div></div> <div class="node lib"><div class="nm">eagle parser <span class="tag t-lib">lib</span></div><div class="dsc">Inside adom-sfconvert. Parses EAGLE/Fusion .lbr XML → KiCad. 100% local.</div></div> </div> </div> <!-- SOURCING --> <div class="tier">- <div class="tier-h"><b>Sourcing</b><em>find the part & its data — each has a baked-in API key in its own service</em></div>+ <div class="tier-h"><b>Sourcing</b><em>find the part & its data, each has a baked-in API key in its own service</em></div> <div class="row"> <div class="node local"><div class="nm">adom-parts-search</div><div class="dsc">The umbrella search.</div><div class="calls">→ <b>mouser</b>, <b>digikey</b>, <b>jlcpcb</b></div></div> <div class="node local"><div class="nm">adom-mouser</div><div class="calls">→ <b>mouser service</b></div></div>@@ -153,16 +153,16 @@ <div class="tier"> <div class="tier-h"><b>Remote service containers</b><em>shared, always-on, big installs you never put on your own container</em></div> <div class="row">- <div class="node remote"><div class="nm">🌐 service-kicad <span class="tag t-remote">remote</span></div><div class="dsc"><b>KiCad 10</b> (~2 GB) headless. DRC/ERC, SVG/Gerber/STEP/GLB export. (Altium import is now native — see altium-schlib.)</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">kicad-rk5ue5pcfemi.adom.cloud</div></div>+ <div class="node remote"><div class="nm">🌐 service-kicad <span class="tag t-remote">remote</span></div><div class="dsc"><b>KiCad 10</b> (~2 GB) headless. DRC/ERC, SVG/Gerber/STEP/GLB export. (Altium import is now native, see altium-schlib.)</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">kicad-rk5ue5pcfemi.adom.cloud</div></div> <div class="node remote"><div class="nm">🌐 step2glb <span class="tag t-remote">remote</span></div><div class="dsc">STEP → GLB 3D conversion (OCCT).</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">step2glb-…adom.cloud</div></div>- <div class="node remote"><div class="nm">🌐 mouser / digikey / jlcpcb <span class="tag t-remote">remote ×3</span></div><div class="dsc">One service each, with the vendor API key baked in — so you never need a key.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">mouser-… · digikey-… · jlcpcb-…</div></div>+ <div class="node remote"><div class="nm">🌐 mouser / digikey / jlcpcb <span class="tag t-remote">remote ×3</span></div><div class="dsc">One service each, with the vendor API key baked in, so you never need a key.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">mouser-… · digikey-… · jlcpcb-…</div></div> <div class="node remote"><div class="nm">🌐 wiki <span class="tag t-remote">remote</span></div><div class="dsc">Publish & install pages/skills.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">wiki.adom.inc</div></div> </div> </div> <!-- DESKTOP --> <div class="tier">- <div class="tier-h"><b>Your laptop</b><em>via the adom-desktop bridge — not a cloud service</em></div>+ <div class="tier-h"><b>Your laptop</b><em>via the adom-desktop bridge, not a cloud service</em></div> <div class="row"> <div class="node desktop"><div class="nm">🖥 real Chrome <span class="tag t-desktop">laptop</span></div><div class="dsc">Native-browser / pup automation for vendor downloads (your real logins).</div></div> <div class="node desktop"><div class="nm">🖥 KiCad / Fusion <span class="tag t-desktop">laptop</span></div><div class="dsc">Open the finished library in your actual EDA app.</div></div>@@ -190,7 +190,7 @@ </div> <div class="note">- <b>On Altium specifically:</b> <b style="color:var(--lib)">altium-pcblib</b> reads Altium footprints and <b style="color:var(--lib)">altium-schlib</b> reads Altium symbols (both native/local). <code>adom-altium</code> is an empty stub. The other Altium pages on the wiki — <code>adom-altium-library-importer</code>, <code>altium-part-importer</code>, <code>adom-desktop-altium-bridge</code> (all barrett-land) — go the <b>opposite</b> direction: they write parts <i>into</i> Altium Designer on the desktop. Reading Altium <b>symbols</b> (.SchLib) is now <b>native too</b> via <code>altium-schlib</code> — so as of this thread <b>nothing foreign touches service-kicad</b>; Altium + EAGLE convert entirely in your container.+ <b>On Altium specifically:</b> <b style="color:var(--lib)">altium-pcblib</b> reads Altium footprints and <b style="color:var(--lib)">altium-schlib</b> reads Altium symbols (both native/local). <code>adom-altium</code> is an empty stub. The other Altium pages on the wiki, <code>adom-altium-library-importer</code>, <code>altium-part-importer</code>, <code>adom-desktop-altium-bridge</code> (all barrett-land), go the <b>opposite</b> direction: they write parts <i>into</i> Altium Designer on the desktop. Reading Altium <b>symbols</b> (.SchLib) is now <b>native too</b> via <code>altium-schlib</code>, so as of this thread <b>nothing foreign touches service-kicad</b>; Altium + EAGLE convert entirely in your container. </div> </div> </body>
tools/hero/compose_hero.pyadded+146@@ -0,0 +1,146 @@+#!/usr/bin/env python3+"""Compose the chip-fetcher hero in the Adom bridge-family style (Pup / KiCad / Fusion).++Same geometry as kicad-bridge's compose_hero.py (itself measured off Pup's+published asset): 2000x1250, Familjen Grotesk Bold title at 242px (cap height+155px, cap top y=263, ink left x=83), Satoshi subtitle at 52px, the ADOM+wordmark and the BRIDGE pill cropped verbatim from Pup's hero, the real app in+a 1030x1025 rounded vitrine at (920,150). The only family deviation: the title+is two lines ("Chip" / "Fetcher") because one word at 240px does not fit.++Usage:+ python3 compose_hero.py <dashboard-capture.png> <out.png>++The capture should be the REAL dashboard at 1030 wide. A 1030x910 in-container+capture gets Pup's Chrome titlebar strip stacked on top to make 1030x1025; a+1030x1025 real-window capture is used as is.+"""+import sys+from pathlib import Path++import numpy as np+from PIL import Image, ImageDraw, ImageFilter, ImageFont++KB = Path("/home/adom/project/adom-desktop-kicad-bridge/skills")+PUP = KB / "kicad-bridge-hero" / "reference-pup-hero.png"+FONTS = KB / "kicad-tour" / "hero" / "fonts"+TITLEBAR = Path("/home/adom/project/pup-bridge/dev-skills/pup-hero-image/assets/win_slot3.png")++W, H = 2000, 1250+TITLE_LINES = ["Chip", "Fetcher"]+SUB_1A, SUB_1B = "Manufacturer-grade chip CAD, ", ""+SUB_2A, SUB_2B = "one prompt to ", "KiCad."+TEAL = (0, 230, 220)++LOGO_CROP, LOGO_AT = (60, 48, 335, 130), (60, 48)+PILL_CROP, PILL_AT = (1796, 36, 1964, 108), (1796, 36)+INK_LEFT = 91 # the studio stamps the standard wordmark with its ink at x=91; title + subtitle align to it+CAP_TOP = 263+LINE_H = 245+SUB_Y1, SUB_Y2 = CAP_TOP + 2 * LINE_H + 0, CAP_TOP + 2 * LINE_H + 64 # below the second title line+VITRINE_AT, VITRINE_W, VITRINE_H = (920, 150), 1030, 1025+RADIUS, SHADOW_BLUR, SHADOW_DY = 24, 28, 12+++def left_ink(im, y0, y1):+ a = np.array(im).astype(int)+ diff = np.abs(a - a[700, 400]).sum(axis=2) > 45+ for x in range(40, 900):+ if diff[y0:y1, x].sum() > 2:+ return x+ return None+++def draw_pill(d, pup):+ """Same pill as the bridge family (position, size, fill sampled from Pup's+ asset), reading APP: Chip Fetcher is an app, not a bridge."""+ fill = (0, 184, 177) # the Adom teal, #00b8b1 (Pup's pill measures (1,182,172))+ text = (5, 34, 31) # --on-accent, #05221f+ x0, y0, x1, y1 = PILL_CROP+ w = 128+ x0 = x1 - w+ d.rounded_rectangle((x0, y0, x1 - 1, y1 - 1), radius=(y1 - y0) // 2, fill=fill)+ pf = ImageFont.truetype(str(FONTS / "FamiljenGrotesk-Bold.ttf"), 34)+ label = "APP"+ tw = d.textlength(label, font=pf)+ bb = pf.getbbox(label)+ d.text((x0 + (w - tw) / 2, y0 + ((y1 - y0) - (bb[3] - bb[1])) / 2 - bb[1]), label, font=pf, fill=text)+++def draw_title(d, tf, dx):+ for i, line in enumerate(TITLE_LINES):+ y = CAP_TOP + i * LINE_H - tf.getbbox(line[0])[1]+ x = dx+ for ch in line:+ d.text((x, y), ch, font=tf, fill=(230, 237, 243))+ x += d.textlength(ch, font=tf) - 5+++def compose(title_dx, sub_dx, pup, base, tf, sf):+ hero = base.copy()+ # wordmark: NOT drawn here; Hero Studio stamps the standard mark (overlay.logo=true, hero-studio#37)+ d = ImageDraw.Draw(hero)+ draw_pill(d, pup)+ draw_title(d, tf, title_dx)+ scol = (232, 238, 244)+ w = d.textlength(SUB_1A, font=sf)+ d.text((sub_dx, SUB_Y1), SUB_1A, font=sf, fill=scol)+ d.text((sub_dx + w, SUB_Y1), SUB_1B, font=sf, fill=scol)+ w = d.textlength(SUB_2A, font=sf)+ d.text((sub_dx, SUB_Y2), SUB_2A, font=sf, fill=scol)+ d.text((sub_dx + w, SUB_Y2), SUB_2B, font=sf, fill=TEAL)+ return hero+++def main(capture_path, out_path):+ pup = Image.open(PUP).convert("RGB")+ pa = np.array(pup)+ tl, tr = pa[6, 6].astype(float), pa[6, W - 7].astype(float)+ bl, br = pa[H - 7, 6].astype(float), pa[H - 7, W - 7].astype(float)+ xs = np.linspace(0, 1, W)[None, :, None]+ ys = np.linspace(0, 1, H)[:, None, None]+ base = Image.fromarray((tl * (1 - xs) * (1 - ys) + tr * xs * (1 - ys)+ + bl * (1 - xs) * ys + br * xs * ys).astype(np.uint8))+ tf = ImageFont.truetype(str(FONTS / "FamiljenGrotesk-Bold.ttf"), 242)+ sf = ImageFont.truetype(str(FONTS / "Satoshi-Regular.ttf"), 52)++ t_dx, s_dx = 98, 84+ im = compose(t_dx, s_dx, pup, base, tf, sf)+ t_dx += INK_LEFT - left_ink(im, CAP_TOP, CAP_TOP + 2 * LINE_H - 40)+ s_dx += INK_LEFT - left_ink(im, SUB_Y1 - 6, SUB_Y2 + 70)+ hero = compose(t_dx, s_dx, pup, base, tf, sf)+ ti = left_ink(hero, CAP_TOP, CAP_TOP + 2 * LINE_H - 40)+ su = left_ink(hero, SUB_Y1 - 6, SUB_Y2 + 70)+ assert (ti, su) == (INK_LEFT, INK_LEFT), f"alignment drifted: {(ti, su)}"++ win = Image.open(capture_path).convert("RGB")+ if win.size[0] != VITRINE_W:+ win = win.resize((VITRINE_W, round(win.size[1] * VITRINE_W / win.size[0])), Image.LANCZOS)+ if win.size[1] < VITRINE_H:+ strip_h = VITRINE_H - win.size[1]+ strip = Image.open(TITLEBAR).convert("RGB")+ strip = strip.crop((12, 0, strip.size[0] - 12, 125)).resize((VITRINE_W, strip_h), Image.LANCZOS)+ stacked = Image.new("RGB", (VITRINE_W, VITRINE_H))+ stacked.paste(strip, (0, 0))+ stacked.paste(win, (0, strip_h))+ win = stacked+ elif win.size[1] > VITRINE_H:+ win = win.crop((0, 0, VITRINE_W, VITRINE_H))++ mask = Image.new("L", (VITRINE_W, VITRINE_H), 0)+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, VITRINE_W - 1, VITRINE_H - 1), radius=RADIUS, fill=255)+ sh = Image.new("L", (W, H), 0)+ vx, vy = VITRINE_AT+ ImageDraw.Draw(sh).rounded_rectangle((vx, vy + SHADOW_DY, vx + VITRINE_W, vy + SHADOW_DY + VITRINE_H),+ radius=RADIUS, fill=110)+ sh = sh.filter(ImageFilter.GaussianBlur(SHADOW_BLUR))+ hero = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)), hero, sh)+ hero.paste(win, VITRINE_AT, mask)+ hero.save(out_path)+ print(f"saved {out_path} (title/sub ink left = {INK_LEFT}, title 242px Familjen Bold, two lines)")+++if __name__ == "__main__":+ if len(sys.argv) != 3:+ sys.exit(__doc__)+ main(sys.argv[1], sys.argv[2])