app
adom-project-manager
Public Made by Adomby adom
DEPRECATED — renamed to Adom Nucleus (adom/adom-nucleus). Kept installable so existing installs and references keep working; depends on adom-nucleus. Install adom/adom-nucleus going forward.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Adom Project Manager</title>
<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>
<style>
:root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}
#list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}
#list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}
#list h1 small{color:var(--mut);font-weight:400}
#refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}
#refresh:hover{background:#243049;border-color:var(--acc)}
#crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}
#crumb a{color:var(--acc);text-decoration:none}
#crumb a:hover{text-decoration:underline}
#crumb .cactive{color:var(--ink);font-weight:600}
.row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}
.tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}
.row .row-tags{margin-top:4px}
.row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}
.tag.kind{text-transform:capitalize}
.tag.kind.board{background:#16324f;color:#79b8ff}
.tag.kind.molecule{background:#463307;color:#f0a81c}
.tag.kind.project{background:#0e3b36;color:#2dd4bf}
.tag.eda:empty{display:none}
.tag.eda.kicad{background:#0b2036;color:#5c9ce6}
.tag.eda.altium{background:#3d3416;color:#f5c518}
.tag.eda.fusion{background:#452510;color:#ff8c33}
.tag.vis{text-transform:capitalize}
.tag.vis.private{background:#3d1416;color:#f87171}
.tag.vis.public{background:#123322;color:#4ade80}
.tagfilter{display:flex;flex-wrap:wrap;gap:5px;padding:2px 16px 8px}
.fchip{font-size:10px;font-weight:600;border-radius:5px;padding:2px 8px;cursor:pointer;border:1px solid var(--edge);background:#12151c;color:var(--mut);user-select:none;text-transform:capitalize;transition:background .12s,color .12s,border-color .12s}
.fchip:hover{border-color:var(--acc)}
.fchip.on{border-color:transparent}
.fchip.on.project{background:#0e3b36;color:#2dd4bf}
.fchip.on.molecule{background:#463307;color:#f0a81c}
.fchip.on.board{background:#16324f;color:#79b8ff}
.fchip.on.kicad{background:#0b2036;color:#5c9ce6}
.fchip.on.altium{background:#3d3416;color:#f5c518}
.fchip.on.fusion{background:#452510;color:#ff8c33}
.cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}
.pubback{cursor:pointer}
.pubback:hover{color:var(--ink)}
.orgrow{display:flex;align-items:center;gap:11px}
.orgrow .orgicon{width:36px;height:36px;flex:none;color:#2dd4bf;opacity:.9;padding:6px;box-sizing:border-box;background:#1a1f2b;border-radius:8px}
.orgrow .orgicon-img{width:36px;height:36px;flex:none;border-radius:8px;object-fit:cover;background:#1a1f2b}
.orgrow .orgname{font-weight:600;color:var(--ink)}
.orgrow .orgmeta{color:var(--mut);font-size:12.5px}
.orgrow .scorebadge{margin-left:0}
.scorebadge{font-size:10px;font-weight:700;color:#f0a81c;background:#463307;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px}
.subhead{padding:6px 16px 4px 20px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);background:#0f1218;font-weight:700}
.row .sub .auth{color:#7f8a9e}
.row .sub .auth::before{content:'· '}
.tabs{display:flex;border-bottom:1px solid var(--edge)}
.tabs .tab{flex:1;background:none;border:none;border-bottom:2px solid transparent;color:var(--mut);padding:10px 4px;font-size:12px;cursor:pointer;white-space:nowrap}
.tabs .tab:hover{color:var(--ink)}
.tabs .tab.active{color:var(--ink);border-bottom-color:var(--acc);font-weight:600}
.row .star{margin-right:6px;color:#f5c518;font-size:13px;line-height:1}
.pubsearch{margin-top:8px}
.pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}
.pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}
.pubsearch input:focus{outline:none;border-color:var(--acc)}
.pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pubsearch button:hover{filter:brightness(1.1)}
.row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}
#toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}
#toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}
#toolbar .tbtns{display:flex;gap:7px}
#toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}
#toolbar button:hover{border-color:var(--acc);background:#22304a}
#toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}
#toolbar button:disabled{opacity:.45;cursor:not-allowed}
.modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}
/* first-load splash: spinning adom mark + fetch progress */
#app-loader{position:fixed;inset:0;background:var(--bg);z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;transition:opacity .45s ease}
#app-loader.hide{opacity:0;pointer-events:none}
#app-loader img{width:150px;height:auto;image-rendering:auto}
#app-loader .lt{font-size:21px;font-weight:700;color:var(--ink);letter-spacing:.01em;margin-top:4px}
#app-loader .lt span{font-weight:400;color:var(--mut)}
#app-loader .lp{color:var(--mut);font-size:13px;letter-spacing:.04em;font-variant-numeric:tabular-nums;margin-top:2px}
#app-loader .lp b{color:var(--ink);font-weight:600}
.modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}
.modal h2{margin:0 0 4px;font-size:18px}
.modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}
.modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}
.modal .opt.sel{border-color:var(--acc);background:#1a2740}
.modal table{width:100%;margin:0 0 6px}
.modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
.modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}
.modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}
.modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}
.modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}
#modal-dest{margin:0 0 16px}
#modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}
#modal-dest .destrow{display:flex;gap:8px;align-items:center}
#modal-dest .browsebtn{flex:2;text-align:left;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px ui-monospace,monospace;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#modal-dest .browsebtn:hover{border-color:var(--acc)}
.fb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
.fb-panel{width:min(560px,92vw);max-height:80vh;background:#12151c;border:1px solid var(--edge);border-radius:12px;display:flex;flex-direction:column;overflow:hidden}
.fb-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--edge)}
.fb-title{font-weight:600}
.fb-x{background:none;border:none;color:var(--mut);font-size:16px;cursor:pointer;line-height:1}
.fb-path{display:block;width:100%;box-sizing:border-box;padding:9px 16px;font:12px ui-monospace,monospace;color:var(--acc);background:#0d0f14;border:none;border-bottom:1px solid var(--edge);outline:none}
.fb-path:focus{border-bottom-color:var(--acc)}
.fb-list{overflow-y:auto;flex:1;min-height:48px;max-height:52vh;padding:6px 0}
.fb-row:last-child{border-bottom:none}
.fb-row{padding:9px 16px;cursor:pointer;font-size:13px;border-bottom:1px solid #171b22;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fb-row:hover{background:#1e222b}
.fb-up{color:var(--acc)}
.fb-empty,.fb-loading{padding:16px;color:var(--mut);font-size:13px;text-align:center}
.fb-foot{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--edge)}
.fb-foot button{border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;border:1px solid var(--edge);background:#0d0f14;color:var(--ink)}
.fb-pick{background:var(--acc)!important;border-color:var(--acc)!important;color:#04121f!important;font-weight:600}
#modal-dest #destname{flex:1}
#modal-dest .destsep{color:var(--mut);font:14px ui-monospace,monospace;flex:0 0 auto}
.libsel{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;max-width:100%}
.libsel:focus{outline:none;border-color:var(--acc)}
.libsel option{background:#262c36;color:var(--ink)}
.inlib td{color:#4ade80}
.inlib .lib{color:#4ade80}
.inlibtag{display:block;margin-top:2px;color:#4ade80;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}
.rmrow td{color:#f87171}
.rmtag{color:#f87171;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}
.lib.addcell{color:#4ade80}
.lib.keepcell{color:var(--ink)}
.lib.rmcell{color:#f87171}
#modal-status{font-size:12px;color:#c9d1d9;padding:7px 10px;border-radius:6px;background:#161b22;border:1px solid var(--edge);margin:2px 0 12px;position:relative;overflow:hidden}
#modal-status:empty{display:none}
#modal-status.done{color:#4ade80;border-color:rgba(74,222,128,.35)}
#modal-status.loading::after{content:'';position:absolute;left:-30%;bottom:0;height:2px;width:30%;background:var(--acc);animation:barslide 1.1s ease-in-out infinite}
@keyframes barslide{to{left:100%}}
#modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}
#modal-dest select{min-width:200px}
#modal-dest input{flex:1;font:12px ui-monospace,monospace}
#modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}
#refresh.spin{animation:sp .6s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
#q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}
#rows{overflow:auto;flex:1}
.row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}
.row:hover{background:#1e222b}
.row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}
.row .mpn{font-weight:600}
.row .sub{color:var(--mut);font-size:12px}
#view{flex:1;overflow:hidden;padding:0;display:flex;flex-direction:column;min-width:0;position:relative}
#cv-close{display:none;position:absolute;right:14px;top:calc(50% + 10px);z-index:6;background:#1a2130;border:1px solid var(--edge);color:var(--ink);width:27px;height:27px;border-radius:6px;cursor:pointer;font-size:14px;line-height:1}
#view.split #cv-close{display:block}
#cv-close:hover{border-color:var(--acc);background:#22304a}
#board-overview{flex:1;overflow:auto;min-height:0}
#component-view{display:none;min-height:0;overflow:auto;padding:16px}
#view.split #board-overview{flex:0 0 50%}
#view.split #component-view{flex:0 0 50%;display:block;border-top:3px solid var(--acc)}
#hint{color:var(--mut);text-align:center;margin-top:20vh}
/* board overview (top of the right pane) */
.render-warn{background:#3a1214;border-bottom:1px solid #7f1d1d;color:#fca5a5;padding:11px 20px;font-size:13px;line-height:1.45;font-weight:500}
.render-warn b{color:#fecaca}
.bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)}
.bo-head .title{margin:0;font-size:20px;font-weight:700}
.bo-author{color:var(--mut);font-size:13px;margin-top:3px}
.bo-hero{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto;border-radius:8px}
.bo-desc{color:var(--ink);font-size:13px;line-height:1.55}
.bo-verwrap{position:relative;display:inline-block;margin-left:8px;vertical-align:3px}
.bo-ver{position:relative;display:inline-block;font-size:11px;font-weight:600;color:var(--acc);background:#12151c;border:1px solid var(--edge);border-radius:6px;padding:2px 8px;cursor:pointer;user-select:none}
.bo-ver:hover{border-color:var(--acc)}
.bo-ver.static{cursor:default}
.bo-ver.static:hover{border-color:var(--edge)}
.bo-verdrop{display:none;position:absolute;top:calc(100% + 5px);left:0;min-width:170px;background:var(--panel);border:1px solid var(--edge);border-radius:8px;box-shadow:0 12px 30px #000a;z-index:50;padding:4px}
.bo-ver:hover .bo-verdrop,.bo-ver:focus-within .bo-verdrop{display:block}
.bo-veropt{display:block;padding:5px 9px;border-radius:5px;font-size:12px;font-weight:500;color:var(--ink);cursor:pointer;white-space:nowrap}
.bo-veropt:hover{background:#243049}
.bo-veropt.on{color:var(--acc)}
.bo-verbanner{margin-top:8px;font-size:12px;color:#f0a81c;background:#2a2109;border:1px solid #4a380e;border-radius:6px;padding:5px 10px}
.bo-verbanner a{color:var(--acc)}
.bo-3d-wrap{position:relative;margin:16px 20px;padding:0}
.bo-3d-wrap .bo-3d{margin:0 auto}
.bo-fs-abs{position:absolute;top:0;right:0;z-index:3}
.bo-3d-wrap .bo-fs-abs{top:10px;right:10px}
.bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto;max-width:none;width:auto;margin:0;align-self:stretch}
/* cap the 3D at a comfortable size and center it (with side gutters) instead of
stretching to the full panel width on wide screens */
.bo-3d{position:relative;aspect-ratio:1;max-width:560px;margin:16px auto;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}
/* controls live INSIDE each view box: fullscreen at top-left, sheet dropdown at top-right */
.bo-boxinner{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden}
.boxfs{position:absolute;top:8px;left:8px;z-index:4}
.boxsheet{position:absolute;top:8px;right:8px;z-index:4;max-width:55%}
.bo-3d model-viewer{width:100%;height:100%;aspect-ratio:auto}
.bo-views{display:flex;gap:14px;padding:0 20px 20px}
.bo-view{flex:1;min-width:0;position:relative}
.bo-view-hd{display:flex;align-items:center;gap:8px;margin-bottom:6px;min-height:24px}
.bo-view h3{margin:0;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
.bo-sheet{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;max-width:62%}
.bo-sheet:focus{outline:none;border-color:var(--acc)}
.bo-hd-ctl{display:flex;align-items:center;gap:8px}
.bo-fs{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:27px;height:24px;font-size:13px;cursor:pointer;line-height:1;padding:0}
.bo-fs:hover{border-color:var(--acc)}
.bo-view.maxed{position:fixed;inset:0;z-index:300;background:var(--bg);margin:0;padding:16px;display:flex;flex-direction:column}
.bo-view.maxed .bo-viewbody{flex:1;height:auto}
.bo-view.maxed .bo-view-hd{margin-bottom:12px}
.bo-viewbody{height:260px;background:#161b22;border:1px solid var(--edge);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;touch-action:none}
.bo-viewbody svg{width:100%;height:100%;display:block}
.bo-na{color:#8b93a3;font-size:13px;text-align:center;padding:20px;line-height:1.5}
/* self-contained interactive board/schematic viewer (adom-layout-viewer embed) */
.bo-viewer{width:100%;height:100%;border:0;display:block;background:#0d1117}
/* In-viewer loading spinner (3D / schematic / PCB): Adom mark + percent below */
.vload{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;width:100%;gap:10px;background:#0d0f14}
.vload.ov{position:absolute;inset:0;z-index:5}
.vload img{width:72px;height:72px;object-fit:contain;image-rendering:auto}
.vload .vload-pct{color:#9fb0c0;font-size:12px;letter-spacing:.06em;font-variant-numeric:tabular-nums}
.bo-section{padding:0 20px 20px}
.bo-section>h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--edge);padding-bottom:6px}
.bo-log .commit{padding:8px 0;border-bottom:1px solid var(--edge)}
.bo-log .commit:last-child{border-bottom:none}
.commit-msg{font-size:13px;color:var(--ink)}
.commit-meta{font-size:11.5px;color:var(--mut);margin-top:2px}
.commit-chips{display:flex;gap:5px;flex-wrap:wrap;margin:3px 0}
.chg{font-size:10px;padding:1px 7px;border-radius:4px;background:#21262e;color:var(--mut);white-space:nowrap}
.chg.add{color:#4ade80}
.chg.rm{color:#f87171}
.commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}
.cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
.card .cardfs{position:absolute;top:7px;right:9px;z-index:4}
/* fullscreen a component card (its embed sidebar reveals itself past the width breakpoint) */
.card.maxed{position:fixed;inset:0;z-index:300;margin:0;border-radius:0}
.card.maxed .body{aspect-ratio:auto;flex:1;min-height:0}
.card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}
.card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}
.card .body svg{max-width:100%;max-height:100%;will-change:transform}
/* Interactive adom-symbol / adom-footprint embed (self-contained HTML in an iframe) */
.card .body.embed{padding:0;cursor:default;background:#0d0f14}
.embed-frame{width:100%;height:100%;border:0;display:block;background:#0d0f14}
.card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}
.lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}
.chip input{margin:0;cursor:pointer}
.chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}
.chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}
model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}
h2.title{margin:0 0 14px}
h2.title small{color:var(--mut);font-weight:400;font-size:14px}
h2.title .wikibtn{display:inline-block;margin-left:14px;font-size:12px;font-weight:600;vertical-align:2px;background:#16324f;color:#79b8ff;border:1px solid #24466a;border-radius:6px;padding:3px 10px;text-decoration:none;cursor:pointer}
h2.title .wikibtn:hover{border-color:var(--acc);background:#1a3a5c}
table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}
th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--edge);font-size:13px}
th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}
tr:last-child td{border-bottom:none}
.swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}
.badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}
.stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
.err{color:#f87171;padding:16px}
.card .body [data-num]{cursor:pointer}
.pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}
rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}
#tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}
#tip b{color:var(--acc)}
</style>
</head>
<body>
<div id="app-loader">
<img src="loader.gif" alt="Loading">
<div class="lt">Adom <span>Project Manager</span></div>
<div class="lp">Fetching Projects: <b id="app-loader-pct">0%</b></div>
</div>
<aside id="list">
<h1>Adom <small>Project Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>
<div id="crumb"></div>
<div id="tabbar" class="tabs"></div>
<input id="q" placeholder="filter boards…">
<div id="tagfilter" class="tagfilter"></div>
<div id="toolbar" style="display:none">
<label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>
<div class="tbtns">
<button id="btn-addlib" disabled>Add to Library</button>
<button id="btn-addproj">Add Project ↓</button>
</div>
</div>
<div id="rows"></div>
</aside>
<main id="view">
<button id="cv-close" onclick="closeComponent()" title="close">✕</button>
<div id="board-overview"><div id="hint">← pick a board to see it here</div></div>
<div id="component-view"></div>
</main>
<div id="modal" class="modal-overlay" style="display:none"><div class="modal">
<h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>
<div id="modal-status" class="libstatus"></div>
<div class="opts" id="modal-opts"></div>
<div id="modal-dest"></div>
<div id="modal-preview"></div>
<div class="sure">Are you sure this is good?</div>
<div class="modal-actions"><button id="modal-cancel">Cancel</button><button class="primary" id="modal-go">Yes, add it</button></div>
</div></div>
<script>
const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};
// Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component
// dependencies ▸ the symbol/footprint/3D viewer.
let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';
let projectSlug=null, projectBoards=[]; // when a PROJECT is open: its board deps (project → boards → components)
// the user's machine env (OS + home + default delivery folders), from /api/env
let env={os:'linux',home:'',sep:'/',dest_folders:[]};
// the user's REAL KiCad libraries (name+uri), from /api/libraries; curPlan = the
// components + their chosen target libraries for the open Add modal
let userLibs={symLibs:[],fpLibs:[]}, curPlan=[];
function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; }
const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};
const isProject=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('project');
const isListable=c=>isBoard(c)||isProject(c);
const qval=()=>document.getElementById('q').value.toLowerCase();
const rowsEl=()=>document.getElementById('rows');
function makeRow(slug,title,sub,onclick,tag,sel){
const d=document.createElement('div');d.className='row';d.dataset.slug=slug;
const cb=sel?`<input type="checkbox" class="selbox"${selected.has(slug)?' checked':''}>`:'';
d.innerHTML=`<div class="mpn">${cb}${title}${tag?`<span class="tag">${tag}</span>`:''}</div><div class="sub">${sub}</div>`;
d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };
return d;
}
// keywords per component type, used to pre-select the closest of the USER's own
// libraries (discovered from their machine) — never hard-coded personal names.
const TYPE_KEYWORDS={'Capacitors':['cap'],'Resistors':['res'],'MOSFETs':['fet','mos'],'Transistors':['fet','transistor','bjt'],'Inductors':['induct','coil','choke'],'LEDs':['led'],'Diodes':['diode'],'Connectors':['conn'],'Microcontrollers':['mcu','micro'],'Power ICs':['power','regulat','pmic'],'Crystals & Oscillators':['crystal','osc','xtal'],'ICs':['ic','misc'],'Other':['misc']};
const selected=new Set();
// best guess among the user's discovered libraries for a component type
function bestLib(libs,type){const kws=TYPE_KEYWORDS[type]||[];for(const kw of kws){const m=libs.find(l=>l.name.toLowerCase().includes(kw));if(m)return m.name;}return libs[0]?libs[0].name:'';}
function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c));}
function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c));}
function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();}
function updateToolbar(){
const n=selected.size;
const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';
const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}
const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s));
}
// First-load splash: the animated adom mark + a "Fetching Projects: XX%" that eases
// toward 90% while the (single, blocking) list request is in flight, then snaps to 100%.
let _ldTimer=null;
function _setPct(p){ const b=document.getElementById('app-loader-pct'); if(b)b.textContent=Math.round(p)+'%'; }
function startLoader(){
const el=document.getElementById('app-loader'); if(!el)return;
el.classList.remove('hide'); let pct=0; _setPct(0);
clearInterval(_ldTimer);
_ldTimer=setInterval(()=>{ pct+=Math.max(0.7,(90-pct)*0.08); if(pct>90)pct=90; _setPct(pct); },80);
}
function finishLoader(){
clearInterval(_ldTimer); _setPct(100);
const el=document.getElementById('app-loader'); if(el)setTimeout(()=>el.classList.add('hide'),280);
}
async function loadList(fresh){
const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');
if(!fresh){ startLoader(); rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>'; }
try{
const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());
all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);
fetch('api/env').then(r=>r.json()).then(e=>{ if(e&&e.os) env=e; }).catch(()=>{});
fetch('api/libraries').then(r=>r.json()).then(l=>{ if(l&&l.symLibs) userLibs=l; }).catch(()=>{});
render();
if(!fresh) finishLoader();
}catch(e){
if(!fresh) finishLoader();
rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';
}finally{ if(btn)btn.classList.remove('spin'); }
}
function render(){ if(boardSlug)renderDeps(); else if(projectSlug)renderProjectBoards(); else renderBoards(); }
// The Personal/Organization/Public nav lives in a persistent bar (not inside the
// scrolling list), so it's always available — even while a board is open. Clicking
// a tab leaves the current project and returns you to that board list.
function renderTabs(){
const boards=all.filter(isListable);
const mineAll=boards.filter(c=>c.owner&&c.owner===me).length;
const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)).length;
const defs=[['mine',`Personal · ${mineAll}`],['org',`Organization · ${orgAll}`],['public','Public']];
const bar=document.getElementById('tabbar');
bar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');
bar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; backToTop(); });
}
// Tag filter chips (kind: molecule/board · EDA: kicad/altium/fusion). Faceted:
// AND across the two facets, OR within a facet. Empty = show everything.
let filterTags=new Set();
const FILTER_CHIPS=[['project','Project'],['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];
// Render the tag-filter chips into any container (shared by the top bar and the public search).
function fillChips(el){
if(!el)return;
el.innerHTML=FILTER_CHIPS.map(([k,label])=>`<span class="fchip ${k}${filterTags.has(k)?' on':''}" data-t="${k}">${label}</span>`).join('');
el.querySelectorAll('.fchip').forEach(ch=>ch.onclick=()=>{ const t=ch.dataset.t; filterTags.has(t)?filterTags.delete(t):filterTags.add(t); renderBoards(); });
}
function renderFilterBar(){
const el=document.getElementById('tagfilter'); if(!el)return;
// Public page renders its chips BELOW the public search box (inside renderPublic), so hide the top bar there.
if(boardTab==='public'){ el.style.display='none'; el.innerHTML=''; return; }
el.style.display=''; fillChips(el);
}
function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }
function isFusionBoard(slug){ const e=boardEdaOf(bySlug[slug]||{slug}); return !!(e&&e.includes('fusion')&&!e.includes('kicad')); }
// 3D orientation is keyed on the GLB's own up-axis stamp (asset.extras.adomMolecule.upAxis),
// NOT the EDA. step2glb --molecule normalizes to Z-up and stamps "z"; glTF/model-viewer is
// Y-up, so a Z-up GLB needs a -90° X tip. Raw (un-pipelined) exports carry no stamp — they're
// already Y-up, so no rotation. This is per-board and correct whether a board went through the
// pipeline or not. The stamp is fetched once per board and cached.
const FUSION_ORIENT='0deg -90deg 0deg';
const upAxisCache={};
async function fetchUpAxis(slug,ver){
const k=slug+'@'+(ver||'');
if(k in upAxisCache) return upAxisCache[k];
try{ const r=await fetch('api/board-upaxis/'+slug+(ver?('?v='+encodeURIComponent(ver)):'')); const d=await r.json(); upAxisCache[k]=d.up_axis||null; }
catch(e){ upAxisCache[k]=null; }
return upAxisCache[k];
}
function mvOrientForAxis(up){ return up==='z' ? ` orientation="${FUSION_ORIENT}"` : ''; }
function matchesFilter(c){
if(!filterTags.size) return true;
const kindSel=['project','molecule','board'].filter(t=>filterTags.has(t));
const edaSel=['kicad','altium','fusion'].filter(t=>filterTags.has(t));
if(kindSel.length && !kindSel.includes(boardKind(c))) return false;
if(edaSel.length){ const eda=boardEdaOf(c); if(!eda||!edaSel.some(t=>eda.includes(t))) return false; }
return true;
}
function renderBoards(){
document.getElementById('toolbar').style.display='none';selected.clear();
document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';
const q=qval(),el=rowsEl();el.innerHTML='';
renderTabs(); renderFilterBar();
const boards=all.filter(isListable);
const mineAll=boards.filter(c=>c.owner&&c.owner===me);
const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org));
const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';
if(boardTab==='public'){renderPublic(boards.filter(matchesFilter),el);return;}
const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)).filter(matchesFilter);
if(!list.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML=boardTab==='mine'?'no personal projects yet — push a board tagged <b>board</b>/<b>molecule</b> (without --org), then press ↻':'no projects in your organization(s) yet';el.appendChild(d);return;}
if(boardTab==='mine'){
sortBoards(list).forEach(c=>el.appendChild(boardRow(c)));
} else {
// group by organization; within each org, split into projects you contribute to
// (you're the author) vs everyone else's.
orgs.forEach(o=>{
const inOrg=list.filter(c=>c.org===o);
if(!inOrg.length)return;
const h=document.createElement('div');h.className='cathead';h.textContent=`${o} · ${inOrg.length}`;el.appendChild(h);
const yours=sortBoards(inOrg.filter(c=>c.owner&&c.owner===me));
const others=sortBoards(inOrg.filter(c=>!c.owner||c.owner!==me));
const section=(label,arr)=>{
if(!arr.length)return;
const sh=document.createElement('div');sh.className='subhead';sh.textContent=`${label} · ${arr.length}`;el.appendChild(sh);
arr.forEach(c=>el.appendChild(boardRow(c)));
};
section('Your Projects',yours);
section('Other Projects',others);
});
}
}
// A board row. Boards you've starred on the wiki show a ★ and pin to the top of
// their list (starred status comes from the wiki API; there's no toggle here).
const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};
function boardKind(c){const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('project')?'project':t.includes('molecule')?'molecule':'board';}
let edaCache={};
// a board can carry more than one toolchain (e.g. KiCad + Fusion) — render one tag each.
function edaTags(list){return (list||[]).map(e=>`<span class="tag eda ${e}">${EDA_LABEL[e]||''}</span>`).join('');}
function loadEda(slug){
const fill=list=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.outerHTML=edaTags(list);});
if(edaCache[slug]!==undefined){fill(edaCache[slug]);return;}
fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||[];fill(edaCache[slug]);}).catch(()=>{});
}
// In-app folder browser: navigates the user's filesystem via the bridge and calls
// onPick(absolutePath) with the real path when the user selects a folder.
function openFolderBrowser(start,onPick){
let cur=start||'';
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
const ov=document.createElement('div'); ov.className='fb-overlay';
ov.innerHTML=`<div class="fb-panel">
<div class="fb-head"><span class="fb-title">Choose a folder</span><button class="fb-x" title="cancel">✕</button></div>
<input class="fb-path" id="fb-path" spellcheck="false" autocomplete="off" placeholder="type or paste a path, press Enter to go">
<div class="fb-list" id="fb-list"><div class="fb-loading">loading…</div></div>
<div class="fb-foot"><button class="fb-cancel">Cancel</button><button class="fb-pick">Select this folder</button></div>
</div>`;
document.body.appendChild(ov);
const close=()=>ov.remove();
ov.querySelector('.fb-x').onclick=close;
ov.querySelector('.fb-cancel').onclick=close;
ov.querySelector('.fb-pick').onclick=()=>{ if(cur){onPick(cur);} close(); };
ov.onclick=e=>{ if(e.target===ov)close(); };
const listEl=ov.querySelector('#fb-list'), pathEl=ov.querySelector('#fb-path');
// type/paste a path + Enter to jump straight there
pathEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); load(pathEl.value.trim()); } });
async function load(p){
listEl.innerHTML='<div class="fb-loading">loading…</div>';
// If the folder listing hasn't come back in 5s, the desktop bridge probably can't
// run shell commands — tell the user what to check instead of spinning forever.
const warn=setTimeout(()=>{ if(listEl.querySelector('.fb-loading')) listEl.innerHTML='<div class="fb-loading">Still loading…<br><span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span></div>'; },5000);
let d;
try{
// use the listing prefetched when the modal opened, so the first open is instant
// even while the modal's library scan is still running on the bridge
if((!p||p==='') && fbPrefetch){ d=await fbPrefetch; fbPrefetch=null; }
if(!d) d=await fetch('api/list-dirs?p='+encodeURIComponent(p||'')).then(r=>r.json());
}
catch(e){ clearTimeout(warn); listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }
clearTimeout(warn);
cur=d.path||p||''; pathEl.value=cur;
let html='';
if(d.parent) html+=`<div class="fb-row fb-up" data-p="${esc(d.parent)}">⬆ ..</div>`;
(d.dirs||[]).forEach(x=>{ html+=`<div class="fb-row" data-p="${esc(x.path)}">📁 ${esc(x.name)}</div>`; });
if(!(d.dirs||[]).length) html+='<div class="fb-empty">(no sub-folders)</div>';
listEl.innerHTML=html;
listEl.querySelectorAll('.fb-row').forEach(r=>r.onclick=()=>load(r.dataset.p));
}
load(cur);
}
function boardRow(c){
const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;
const star=c.starred?`<span class="star" title="starred on the wiki">★</span>`:'';
const kind=boardKind(c);
// eda arrives with the list (computed server-side in parallel); only fall back
// to the per-row fetch when it's absent.
const edaKnown=Array.isArray(c.eda);
const edaSpan=edaKnown?edaTags(c.eda):`<span class="tag eda" data-eda="${c.slug}"></span>`;
const vis=(c.visibility||'').toLowerCase();
const visClass=vis==='private'?'private':(vis==='public'||vis==='open')?'public':'';
const visTag=vis?`<span class="tag vis ${visClass}">${vis==='open'?'public':vis}</span>`:'';
d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}${c.owner?`<span class="auth">${c.owner}</span>`:''}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}${visTag}</div>`;
d.onclick=()=>isProject(c)?openProject(c.slug):openBoard(c.slug);
if(!edaKnown)loadEda(c.slug);
return d;
}
function sortBoards(list){
return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase()));
}
// The Public tab. DEFAULT (no search) = a popularity leaderboard: top 10 most
// popular public projects, then top 10 most popular orgs. Typing a query clears
// the leaderboard and shows normal filtered results. Clicking an org drills into
// that org's public projects.
// If an org has no avatar (or the proxy fails), fall back to the generic building glyph.
function orgIconErr(img){ img.outerHTML=ORG_ICON; }
const ORG_ICON='<svg class="orgicon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 21V6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v3h7a1 1 0 0 1 1 1v11H3zm2-2h2v-2H5v2zm0-4h2v-2H5v2zm0-4h2V9H5v2zm0-4h2V7H5v2zm4 12h2v-2H9v2zm0-4h2v-2H9v2zm0-4h2V9H9v2zm0-4h2V7H9v2zm4 12h6v-2h-6v2zm2-4h2v-2h-2v2zm0-4h2v-2h-2v2z"/></svg>';
let pubOrg=null, pubBoards=[];
function renderPublic(boards,el){
pubBoards=boards.filter(c=>{const v=(c.visibility||'').toLowerCase();return v==='public'||v==='open';});
const wrap=document.createElement('div');wrap.className='pubsearch';
wrap.innerHTML=`<div class="pubrow"><input id="pubq" placeholder="search public projects"></div><div id="tagfilter-pub" class="tagfilter"></div><div id="pubresults"></div>`;
el.appendChild(wrap);
fillChips(document.getElementById('tagfilter-pub')); // tag filters sit BELOW the search on the public page
const pubq=document.getElementById('pubq');pubq.value=pubQuery;
const doPub=()=>{pubQuery=pubq.value;pubOrg=null;renderPubResults();};
pubq.oninput=()=>{ pubQuery=pubq.value; pubOrg=null; if(!pubq.value.trim()){pubQuery='';} renderPubResults(); }; // live search; cleared → leaderboard
pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};
renderPubResults();
}
function renderPubResults(){
const pr=document.getElementById('pubresults'); if(!pr)return; pr.innerHTML='';
const pub=pubBoards;
if(pubOrg){ // drilled into one org
const back=document.createElement('div');back.className='cathead pubback';back.innerHTML=`← ${pubOrg} · public projects`;back.onclick=()=>{pubOrg=null;renderPubResults();};pr.appendChild(back);
sortBoards(pub.filter(c=>c.org===pubOrg)).forEach(c=>pr.appendChild(boardRow(c)));
return;
}
const pq=pubQuery.trim().toLowerCase();
if(pq){ // normal search
const m=pub.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));
if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;}
sortBoards(m).forEach(c=>pr.appendChild(boardRow(c)));
return;
}
// DEFAULT leaderboard: top 10 popular projects
const topBoards=[...pub].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,5);
const h1=document.createElement('div');h1.className='cathead';h1.textContent='Trending';pr.appendChild(h1);
if(!topBoards.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent='no public projects yet';pr.appendChild(d);}
topBoards.forEach(c=>{const row=boardRow(c);const m=row.querySelector('.mpn');if(m)m.insertAdjacentHTML('beforeend',` <span class="scorebadge" title="popularity score">▲ ${Math.round(c.score||0)}</span>`);pr.appendChild(row);});
// top 10 popular organizations (by aggregate score of their public projects)
const agg={};
pub.forEach(c=>{const o=c.org;if(o){const a=agg[o]=agg[o]||{score:0,count:0};a.score+=(c.score||0);a.count++;}});
const topOrgs=Object.entries(agg).sort((a,b)=>b[1].score-a[1].score).slice(0,5);
if(topOrgs.length){
const h2=document.createElement('div');h2.className='cathead';h2.textContent='Popular Organizations';pr.appendChild(h2);
topOrgs.forEach(([org,a],i)=>{
const d=document.createElement('div');d.className='row orgrow';
d.innerHTML=`<img class="orgicon-img" src="org-avatar/${encodeURIComponent(org)}" alt="" onerror="orgIconErr(this)"><span class="orgname">${org}</span><span class="orgmeta">· ${a.count} project${a.count>1?'s':''} ·</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span>`;
d.onclick=()=>{pubOrg=org;renderPubResults();};
pr.appendChild(d);
});
}
}
async function openBoard(slug){
boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;
const title=(bySlug[slug]||{}).title||slug;
document.getElementById('crumb').innerHTML=projectSlug
? `<a href="#" onclick="backToTop();return false">Projects</a> / <a href="#" onclick="openProject('${projectSlug}');return false">${(bySlug[projectSlug]||{}).title||projectSlug}</a> / <span class="cactive">${title}</span>`
: `<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;
renderBoardOverview(slug);
rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';
try{ const r=await fetch('api/board/'+slug).then(r=>r.json());
if(boardSlug!==slug)return; // navigated back/away while loading — don't paint stale parts
curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }
catch(e){ if(boardSlug!==slug)return; curDeps=[]; }
if(boardSlug!==slug)return;
renderDeps();
}
// The board overview (top of the right pane): name + author, a 3D view of the
// board (from a wiki GLB if present), and small KiCad schematic + PCB views.
function renderBoardOverview(slug){
const c=bySlug[slug]||{slug};
const _vis=(c.visibility||'').toLowerCase();
const _visTag=_vis?`<span class="tag vis ${_vis==='private'?'private':(_vis==='public'||_vis==='open')?'public':''}">${_vis==='open'?'public':_vis}</span>`:'';
const view=document.getElementById('view');
view.classList.remove('split');
document.getElementById('component-view').innerHTML='';
document.getElementById('board-overview').innerHTML=`
<div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind ${boardKind(c)}">${boardKind(c)}</span><span class="tag eda" data-eda="${slug}"></span>${_visTag}<span id="bo-version" class="bo-verwrap"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div><div id="bo-verbanner" class="bo-verbanner" style="display:none"></div></div>
<div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d">${viewerLoaderHTML('loading…')}</div></div></div>
<div class="bo-views">
<div class="bo-view"><div class="bo-view-hd"><h3>Schematic</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><select id="bo-sheet" class="bo-sheet boxsheet" style="display:none"></select><div class="bo-boxinner" id="bo-sch">${viewerLoaderHTML('rendering…')}</div></div></div>
<div class="bo-view"><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo-pcb">${viewerLoaderHTML('rendering…')}</div></div></div>
</div>
<div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`;
loadBoardMedia(slug);
loadEda(slug);
}
function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));}
function timeago(ts){
if(!ts)return'';const s=Math.floor(Date.now()/1000)-ts;
if(s<60)return'just now';const m=Math.floor(s/60);if(m<60)return m+'m ago';
const h=Math.floor(m/60);if(h<24)return h+'h ago';const d=Math.floor(h/24);if(d<30)return d+'d ago';
const mo=Math.floor(d/30);if(mo<12)return mo+'mo ago';return Math.floor(mo/12)+'y ago';
}
function commitHtml(c){
const chips=[];
if(c.comp_added)chips.push(`<span class="chg add">+${c.comp_added} component${c.comp_added>1?'s':''}</span>`);
if(c.comp_removed)chips.push(`<span class="chg rm">−${c.comp_removed} component${c.comp_removed>1?'s':''}</span>`);
if(c.pcb_changed)chips.push(`<span class="chg">PCB changed</span>`);
if(c.sch_changed)chips.push(`<span class="chg">schematic changed</span>`);
const chipsHtml=chips.length?`<div class="commit-chips">${chips.join('')}</div>`:'';
return `<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div>${chipsHtml}<div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`;
}
function fillBoardInfo(slug,log){
if(boardSlug!==slug)return;
const lg=document.getElementById('bo-log');
if(!lg)return;
lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';
// enrich each entry with what changed (components +/-, PCB/schematic) — lazily,
// since it re-fetches each commit's PCB/schematic to diff them
fetch('api/board-activity/'+slug).then(r=>r.json()).then(d=>{
if(boardSlug!==slug)return;
const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');
}).catch(()=>{});
}
// Red error bar at the top only when the page is actually missing the assets it
// needs to display (checked first via has_assets) — a board that still has its
// KiCad source / 3D model renders fine (live) and gets NO bar even without render/.
function showRenderWarning(slug,m){
if(boardSlug!==slug)return;
const ov=document.getElementById('board-overview'); if(!ov)return;
let bar=ov.querySelector('.render-warn');
const missing=m&&m.is_kicad!==false&&m.has_assets===false;
if(!missing){ if(bar)bar.remove(); return; }
const kind=boardKind(bySlug[slug]||{});
if(!bar){
bar=document.createElement('div'); bar.className='render-warn';
ov.insertBefore(bar,ov.firstChild);
}
bar.innerHTML=`⚠ This ${kind} is missing the assets it needs to render (its KiCad board/schematic or 3D model aren't on the page), so its 3D, PCB and schematic won't load. Reupload the ${kind} with the <b>publish-board</b> skill.`;
}
async function loadBoardMedia(slug){
// service-kicad renders in the background (a board GLB can take ~a minute) —
// poll until it's done, keeping the "rendering…" placeholders meanwhile.
let m={rendering:true};
for(let i=0;i<60 && m.rendering;i++){
try{ m=await fetch('api/board-media/'+slug).then(r=>r.json()); }catch(e){ m={}; break; }
if(boardSlug!==slug)return; // navigated away
showRenderWarning(slug,m); // flag a missing render/ folder as soon as we know
if(m.rendering)await new Promise(r=>setTimeout(r,2000));
}
if(boardSlug!==slug)return;
showRenderWarning(slug,m);
// non-KiCad boards (EAGLE/Fusion, or 3D-only) still show their 3D via the model
// GLB; the 2D PCB/schematic panes just render "not available" (no KiCad renderer).
curBoardMedia=m; boardVersion=null;
renderVersionPill(); updateVerBanner();
await mountBoardAssets(slug);
}
// Version selector state: boardVersion = the selected PAST version (null = current).
let boardVersion=null, curBoardMedia=null;
function verQ(){
const m=curBoardMedia||{};
if(!(boardVersion && m.current && boardVersion!==m.current)) return '';
// Append the version's content hash so the URL is content-addressed — the browser (and
// model-viewer) can't serve a stale cache entry for this ?v from a different render.
const e=(m.versions||[]).find(x=>x.version===boardVersion);
const h=(e&&e.hash)?'&h='+encodeURIComponent(e.hash):'';
return '?v='+encodeURIComponent(boardVersion)+h;
}
// Mount the 3D / PCB / schematic views for the selected version. Re-called when the
// version pill changes — each asset fetch carries ?v=<version> for a past version.
async function mountBoardAssets(slug){
const m=curBoardMedia||{}, q=verQ();
// Fusion/EAGLE renders carry their own colors baked in: the PCB already has the dark
// editor canvas + pre-dimmed copper pour (so skip dimFills, and match the pane bg to
// the #282828 canvas the paper-rect strip removes), and the schematic is on white
// (EAGLE) so the pane goes white — makes it read like it's inside Fusion.
const fusion=isFusionBoard(slug);
const schOpt=fusion?{light:true,bg:'#ffffff'}:{light:true};
const pcbOpt=fusion?{bg:'#282828'}:{dimFills:0.4};
const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');
// Instant feedback on a version switch: blank each pane to the loading spinner NOW,
// before any await, so the previously-selected version's view doesn't linger while
// the new version's assets fetch.
if(d3 && m.has_3d){ d3.style.position='relative'; d3.style.background='#0d0f14'; showViewerLoader(d3); }
if(sch && (m.sheets||[]).length) showViewerLoader(sch);
if(pcb && m.has_pcb) showViewerLoader(pcb);
if(d3){
if(m.has_3d){
const up=await fetchUpAxis(slug, boardVersion); // GLB up-axis stamp → per-board orientation
if(boardSlug!==slug)return;
d3.style.position='relative'; d3.style.background='#0d0f14';
d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`+
`<div class="vload ov"><img src="loader.gif" alt=""></div>`;
const mv=d3.querySelector('model-viewer'), ov=d3.querySelector('.vload.ov'), pct=ov.querySelector('.vload-pct');
const done=()=>{ if(ov)ov.remove(); };
// model-viewer streams the GLB and reports detail.totalProgress 0→1.
mv.addEventListener('progress',e=>{ const p=e.detail&&e.detail.totalProgress; if(p!=null){ setViewerPct(pct,p); if(p>=1)done(); } });
mv.addEventListener('load',done);
} else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;
}
const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');
// Interactive viewers available for the version being shown. On a PAST version this
// is that version's own archived list, so a board revision without archived viewers
// falls back to its own static SVG instead of showing a newer revision.
const vlist=viewersFor(m);
const schViewer=(i)=>{ const n='sch-'+String(sheets[i]||'').toLowerCase(); return vlist.includes(n)?n:null; };
if(sch){
if(sheets.length){
const showSheet=async(i)=>{
if(boardSlug!==slug)return;
const vn=schViewer(i);
if(vn) return mountViewer(sch, `board-viewer/${slug}/${vn}${verQ()}`);
const p=showViewerLoader(sch);
mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${i}${verQ()}`,p), schOpt);
};
if(sel){
sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join('');
sel.style.display=sheets.length>1?'':'none';
sel.onchange=()=>showSheet(sel.value);
}
await showSheet(0);
} else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }
}
if(boardSlug!==slug)return;
if(pcb){
if(vlist.includes('layout')) mountViewer(pcb, `board-viewer/${slug}/layout${q}`);
else if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), pcbOpt); }
else pcb.innerHTML='<div class="bo-na">PCB view not available</div>';
}
}
// Which interactive viewers apply to the version currently selected.
function viewersFor(m){
if(!boardVersion) return m.viewers||[];
const ent=(m.versions||[]).find(v=>v.version===boardVersion);
return (ent&&ent.viewers)||[];
}
// Mount a self-contained interactive viewer (iframe). It ships its own chrome
// (layers/nets panel, hover cards), so no mountSvg post-processing applies.
function mountViewer(el, src){
el.style.position='relative'; el.style.background='#0d1117';
el.innerHTML=`<iframe class="bo-viewer" src="${src}" title="interactive board view"></iframe>`+
`<div class="vload ov"><img src="loader.gif" alt=""></div>`;
const fr=el.querySelector('iframe'), ov=el.querySelector('.vload.ov');
const done=()=>{ if(ov&&ov.parentNode) ov.remove(); };
fr.addEventListener('load',done);
setTimeout(done,15000); // never strand the spinner if load never fires
}
function selectVersion(v){
const m=curBoardMedia||{};
boardVersion=(v==null||v===m.current)?null:v;
renderVersionPill(); updateVerBanner(); mountBoardAssets(boardSlug);
}
function renderVersionPill(){
const el=document.getElementById('bo-version'); if(!el)return;
const m=curBoardMedia||{}, vers=Array.isArray(m.versions)?m.versions:[];
const pkgVer=(bySlug[boardSlug||projectSlug]||{}).version; // wiki package version (always present)
const cur=boardVersion||m.current||(vers[0]&&vers[0].version)||pkgVer;
if(!cur){ el.innerHTML=''; return; } // no version info at all
if(vers.length>1){ // multiple archived → clickable dropdown
el.innerHTML=`<span class="bo-ver" tabindex="0">v${cur} ▾<span class="bo-verdrop">${vers.map(v=>`<span class="bo-veropt${v.version===cur?' on':''}" onclick="event.stopPropagation();selectVersion('${v.version}')">v${v.version}${v.version===m.current?' · current':''}</span>`).join('')}</span></span>`;
} else { // single/no archive → static version label
el.innerHTML=`<span class="bo-ver static">v${cur}</span>`;
}
}
function updateVerBanner(){
const b=document.getElementById('bo-verbanner'), m=curBoardMedia||{}; if(!b)return;
const off=boardVersion&&m.current&&boardVersion!==m.current;
b.style.display=off?'':'none';
if(off)b.innerHTML=`Viewing <b>v${boardVersion}</b> — not the current version. <a href="#" onclick="selectVersion(null);return false">Back to v${m.current}</a>`;
}
// Loading UI shared by the 3D / schematic / PCB viewers: the Adom mark + a percent below.
function setViewerPct(el,frac){ if(el) el.textContent=Math.round(Math.max(0,Math.min(1,frac))*100)+'%'; }
// Adom-mark spinner markup with a status/percent label below it (default "0%").
function viewerLoaderHTML(label){ return `<div class="vload"><img src="loader.gif" alt=""></div>`; }
// Fill a viewer box with the loading spinner; returns the percent/status <span> to update.
function showViewerLoader(box,label){ box.style.background='#0d0f14'; box.innerHTML=viewerLoaderHTML(label); return box.querySelector('.vload-pct'); }
// Fetch text while streaming download progress into a percent element (Content-Length based;
// falls back to KB counter if the length is unknown, then a plain text() on any stream error).
async function fetchTextProgress(url,pctEl){
let r; try{ r=await fetch(url); }catch(e){ throw e; }
if(!r.ok) return await r.text();
if(!r.body||!r.body.getReader) return await r.text();
const total=+(r.headers.get('content-length')||0);
try{
const reader=r.body.getReader(); let got=0; const chunks=[];
for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;
if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }
if(pctEl&&total>0) setViewerPct(pctEl,1);
const buf=new Uint8Array(got); let off=0; for(const c of chunks){ buf.set(c,off); off+=c.length; }
return new TextDecoder().decode(buf);
}catch(e){ return await r.text(); }
}
// Like fetchTextProgress but returns a Blob (for the project 3D GLB, which we load via a blob URL).
async function fetchBlobProgress(url,pctEl){
const r=await fetch(url);
if(!r.ok) return null;
if(!r.body||!r.body.getReader) return await r.blob();
const total=+(r.headers.get('content-length')||0);
try{
const reader=r.body.getReader(); let got=0; const chunks=[];
for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;
if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }
if(pctEl&&total>0) setViewerPct(pctEl,1);
return new Blob(chunks,{type:r.headers.get('content-type')||'model/gltf-binary'});
}catch(e){ return await r.blob(); }
}
// Inject an SVG on a dark background: drop the full-page paper rect (so the dark
// box shows through AND the content crops tight via getBBox), optionally invert
// for the PCB, then enable pan/zoom.
function mountSvg(box,svgText,opt){
opt=opt||{};
// parse as real SVG (innerHTML with the XML prolog doesn't expose the <g> tree)
let svg;
try{ svg=new DOMParser().parseFromString(svgText,'image/svg+xml').documentElement; }catch(e){}
if(!svg||String(svg.nodeName).toLowerCase()!=='svg'){box.innerHTML='<div class="bo-na">view not available</div>';return;}
box.innerHTML=''; box.appendChild(svg);
box.style.background=opt.bg||(opt.light?'#f4f3ee':'#0d0f14');
svg.removeAttribute('width');svg.removeAttribute('height');
svg.style.width='100%';svg.style.height='100%';svg.style.display='block';
if(opt.invert)svg.style.filter='invert(1) hue-rotate(180deg)';
// dim ONLY the copper zone pours (the large filled ground/power planes) so lower
// layers show through; pads, vias, traces and silk stay fully opaque.
if(opt.dimFills){
// drill holes first (unchanged): via (<0.5mm) → gold, larger holes → background
svg.querySelectorAll('[style*="fill"]').forEach(el=>{
const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/);
if(!m||m[1].toLowerCase()!=='#ffffff')return;
// ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)
const circles=el.querySelectorAll('[r]');
if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; });
else el.style.fill='#0d0f14';
});
// Identify copper POURS/zones by SHAPE, not size: a poured zone is filled with
// fill-rule:evenodd because it carries clearance cutouts around every pad/trace,
// while pads and simple fills are nonzero. This is area-independent, so it catches
// small zones the old `d.length ≥ 2000` heuristic missed. Traces are stroke-only
// (fill:none). Silk/edge fills are evenodd too (letter holes), so they're skipped
// BY COLOR — keeping a filled silk logo fully opaque no matter its size.
const skip=new Set([
'rgb(242, 237, 161)','rgb(255, 230, 0)','rgb(255, 229, 0)', // silk (kicad + altium yellow)
'rgb(208, 210, 205)','rgb(255, 255, 255)']); // edge, white
svg.querySelectorAll('path').forEach(el=>{
if((getComputedStyle(el).fillRule||'').toLowerCase()!=='evenodd') return; // only poured zones → dim; pads/simple fills opaque
const f=getComputedStyle(el).fill; // resolves fill inherited from a parent <g>
if(!f||f==='none'||f==='rgba(0, 0, 0, 0)'||skip.has(f)) return; // traces / edge / silk → opaque
el.style.fillOpacity=opt.dimFills; // copper pour → translucent
});
}
// remove the full-page paper rect (crop to the content on the box background)
{
const vb=svg.viewBox.baseVal;
if(vb)[...svg.querySelectorAll('rect')].forEach(r=>{const w=+r.getAttribute('width')||0,h=+r.getAttribute('height')||0;if(w>=vb.width*0.92&&h>=vb.height*0.92)r.remove();});
}
try{const bb=svg.getBBox();if(bb.width&&bb.height){const pad=Math.max(bb.width,bb.height)*0.03;svg.setAttribute('viewBox',`${bb.x-pad} ${bb.y-pad} ${bb.width+2*pad} ${bb.height+2*pad}`);}}catch(e){}
panZoomSvg(svg);
}
function panZoomSvg(svg){
const vb=svg.viewBox.baseVal;svg.style.cursor='grab';let drag=null;
svg.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY};svg.setPointerCapture(e.pointerId);svg.style.cursor='grabbing';});
svg.addEventListener('pointermove',e=>{if(!drag)return;const r=svg.getBoundingClientRect();vb.x-=(e.clientX-drag.x)*vb.width/r.width;vb.y-=(e.clientY-drag.y)*vb.height/r.height;drag={x:e.clientX,y:e.clientY};});
const end=()=>{drag=null;svg.style.cursor='grab';};
svg.addEventListener('pointerup',end);svg.addEventListener('pointercancel',end);
svg.addEventListener('wheel',e=>{e.preventDefault();const r=svg.getBoundingClientRect();const px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height;const mx=vb.x+px*vb.width,my=vb.y+py*vb.height;const f=e.deltaY<0?0.85:1.18;vb.width*=f;vb.height*=f;vb.x=mx-px*vb.width;vb.y=my-py*vb.height;},{passive:false});
}
// Infer a component's type from its tags + brief + title (most parts carry no
// explicit category, so keywords in the brief are the signal).
const TYPE_RULES=[
[/microcontroller|\bmcu\b|\bmpu\b|\bsoc\b/,'Microcontrollers'],
[/regulator|\bldo\b|dc-?dc|buck|boost|power management|\bpmic\b/,'Power ICs'],
[/mosfet|\bfet\b/,'MOSFETs'],
[/transistor|\bbjt\b|\bnpn\b|\bpnp\b/,'Transistors'],
[/\bled\b/,'LEDs'],
[/diode|schottky|zener|\btvs\b|rectifier/,'Diodes'],
[/mlcc|capacitor|\bcap\b|tantalum/,'Capacitors'],
[/resistor|thick film|thin film/,'Resistors'],
[/inductor|chip coil|\bcoil\b|ferrite|choke|\bbead\b/,'Inductors'],
[/crystal|oscillator|resonator/,'Crystals & Oscillators'],
[/connector|header|receptacle|socket|\bjack\b|\busb\b/,'Connectors'],
[/switch|button|tactile/,'Switches'],
[/\bfuse\b/,'Fuses'],
[/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'],
];
const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];
function typeOf(c){
const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();
for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;
return 'Other';
}
function renderDeps(){
renderTabs();
document.getElementById('tagfilter').style.display='none';
document.getElementById('q').placeholder='filter components…';
document.getElementById('toolbar').style.display='flex';
const q=qval(),el=rowsEl();el.innerHTML='';
if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}
const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))
.filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));
const groups={};
items.forEach(x=>{const t=typeOf(x.c);(groups[t]=groups[t]||[]).push(x);});
const cats=Object.keys(groups).sort((a,b)=>{const ia=TYPE_ORDER.indexOf(a),ib=TYPE_ORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib)||a.localeCompare(b);});
cats.forEach(cat=>{
const h=document.createElement('div');h.className='cathead';h.textContent=`${cat} · ${groups[cat].length}`;el.appendChild(h);
groups[cat].forEach(x=>{const on=bySlug[x.slug];el.appendChild(makeRow(x.slug,x.c.title||x.slug,on?x.slug:x.slug+' · not on wiki',openPart,null,true));});
});
updateToolbar();
}
function backToBoards(){
boardSlug=null;curDeps=[];document.getElementById('q').value='';
const view=document.getElementById('view');view.classList.remove('split');
document.getElementById('component-view').innerHTML='';
if(projectSlug){ openProject(projectSlug); return; } // inside a project → back to its board list
document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';
render();
}
function backToTop(){
projectSlug=null; boardSlug=null; curDeps=[]; projectBoards=[];
document.getElementById('q').value='';
document.getElementById('view').classList.remove('split');
document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';
document.getElementById('component-view').innerHTML='';
render();
}
// ---- Projects: a page tagged `project` whose deps are BOARDS. Opening one lists
// its boards in the left pane and shows a simple overview (3D · description ·
// activity) on the right; clicking a board opens it normally (board → components).
async function openProject(slug){
projectSlug=slug; boardSlug=null; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;
renderProjectOverview(slug);
rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards…</div>';
try{ const r=await fetch('api/project/'+slug).then(r=>r.json());
if(projectSlug!==slug)return;
projectBoards=(r.boards||[]).map(d=>d.slug);
fillProjectInfo(slug,r.log);
}catch(e){ if(projectSlug!==slug)return; projectBoards=[]; }
if(projectSlug!==slug)return;
renderProjectBoards();
}
function renderProjectOverview(slug){
const c=bySlug[slug]||{slug};
const _vis=(c.visibility||'').toLowerCase();
const _visTag=_vis?`<span class="tag vis ${_vis==='private'?'private':(_vis==='public'||_vis==='open')?'public':''}">${_vis==='open'?'public':_vis}</span>`:'';
document.getElementById('view').classList.remove('split');
document.getElementById('component-view').innerHTML='';
document.getElementById('board-overview').innerHTML=`
<div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind project">project</span>${_visTag}<span id="bo-version" class="bo-verwrap"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div><div id="bo-verbanner" class="bo-verbanner" style="display:none"></div></div>
<div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d">${viewerLoaderHTML('loading…')}</div></div></div>
<div class="bo-section"><h3>Description</h3><div class="bo-desc" id="bo-desc">${esc(c.brief||'')||'<span class="bo-na" style="text-align:left">no description</span>'}</div></div>
<div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`;
curBoardMedia={}; renderVersionPill();
loadProject3d(slug);
}
async function loadProject3d(slug){
const d3=document.getElementById('bo3d'); if(!d3)return;
const pct=showViewerLoader(d3); // Adom mark + percent while the GLB streams down
try{
const blob=await fetchBlobProgress('board-glb/'+slug,pct);
if(projectSlug!==slug)return;
if(blob){ const up=await fetchUpAxis(slug,null); if(projectSlug!==slug)return; const url=URL.createObjectURL(blob); d3.style.background='#0d0f14'; d3.innerHTML=`<model-viewer src="${url}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`; return; }
}catch(e){}
if(projectSlug!==slug)return;
// no GLB yet → fall back to the page's hero image (proxied server-side, any hero type)
d3.innerHTML='';
const img=document.createElement('img'); img.className='bo-hero';
img.src='board-hero/'+slug;
img.onerror=()=>{ if(projectSlug===slug)d3.innerHTML='<div class="bo-na">3D not available</div>'; };
d3.appendChild(img);
}
function fillProjectInfo(slug,log){
if(projectSlug!==slug)return;
const lg=document.getElementById('bo-log'); if(!lg)return;
lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';
}
function renderProjectBoards(){
renderTabs();
document.getElementById('tagfilter').style.display='none';
document.getElementById('toolbar').style.display='none';
const qbox=document.getElementById('q'); qbox.style.display=''; qbox.placeholder='filter boards…';
const q=qval(),el=rowsEl();el.innerHTML='';
const title=(bySlug[projectSlug]||{}).title||projectSlug;
document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToTop();return false">← Projects</a> / <span class="cactive">${title}</span>`;
const boards=projectBoards.map(s=>bySlug[s]||{slug:s}).filter(c=>!q||c.slug.includes(q)||((c.title||'').toLowerCase().includes(q)));
if(!boards.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this project lists no boards</div>';return;}
const h=document.createElement('div');h.className='cathead';h.textContent=`Boards · ${boards.length}`;el.appendChild(h);
sortBoards(boards).forEach(c=>el.appendChild(boardRow(c)));
}
// Close the bottom-half component view (the ✕), back to just the board overview.
function closeComponent(){
document.getElementById('view').classList.remove('split');
document.getElementById('component-view').innerHTML='';
document.querySelectorAll('.row.sel').forEach(r=>r.classList.remove('sel'));
}
// Maximize a schematic/PCB view to fill the webview page (not the OS screen);
// click the button again to restore.
function toggleFs(btn){
const view=btn.closest('.bo-view')||btn.closest('.card'); // board viewers OR component cards
if(view)view.classList.toggle('maxed');
}
async function openPart(slug,el){
document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));
if(el)el.classList.add('sel');
const view=document.getElementById('view');view.classList.add('split');
const v=document.getElementById('component-view');
v.innerHTML='<div id="hint" style="margin-top:6vh">loading '+slug+'…</div>';
const p=await fetch('api/part/'+slug).then(r=>r.json());
if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}
const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';
let rows=(p.layers||[]).map(l=>`<tr>
<td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>
<td><span class="badge">${l.kicad}</span></td>
<td><span class="badge">${l.altium}</span></td></tr>`).join('');
const wc=bySlug[slug]||{}; const wowner=wc.org||wc.owner||'';
const wikiUrl=wowner?`https://wiki.adom.inc/${wowner}/${encodeURIComponent(slug)}`:'';
v.innerHTML=`
<h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small>${wikiUrl?`<a class="wikibtn" href="${wikiUrl}" target="_blank" rel="noopener">Open on wiki ↗</a>`:''}</h2>
<div class="cols">
<div class="card"><h3>Schematic Symbol</h3><button class="bo-fs cardfs" title="fullscreen (shows the pin panel)" onclick="toggleFs(this)">⛶</button><div class="body embed"><iframe class="embed-frame" src="part-embed/${slug}/symbol" title="schematic symbol" loading="lazy"></iframe></div></div>
<div class="card"><h3>Footprint</h3><button class="bo-fs cardfs" title="fullscreen (shows the layers panel)" onclick="toggleFs(this)">⛶</button>
<div class="body embed" id="fpbody"><iframe class="embed-frame" src="part-embed/${slug}/footprint" title="footprint" loading="lazy"></iframe></div></div>
<div class="card"><h3>3D Model</h3>${glb}</div>
</div>
<div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>
<table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>
<tbody>${rows}</tbody></table>`;
// Only the 3D "no model" fallback body is pannable now; symbol/footprint are
// self-contained embed iframes with their own pan/zoom + pin-info/layers panels.
v.querySelectorAll('.card .body:not(.embed)').forEach(makePannable);
}
// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and
// name/description in a tooltip. Pins/pads carry data-num + data-name.
function setupHighlight(v){
let tip=document.getElementById('tip');
if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}
const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));
v.querySelectorAll('.card .body').forEach(box=>{
box.addEventListener('pointermove',e=>{
const hit=e.target.closest('[data-num]');
clear(box);
if(hit){
hit.classList.add('pinhi');
const num=hit.dataset.num, nm=hit.dataset.name;
const kind=hit.closest('#fpbody')?'Pad':'Pin';
tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;
tip.style.display='block';
tip.style.left=(e.clientX+14)+'px';
tip.style.top=(e.clientY+14)+'px';
} else { tip.style.display='none'; }
});
box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});
});
}
// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag
// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.
function makePannable(box){
const svg=box.querySelector('svg');
if(!svg) return;
let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;
const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};
box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};
box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};
box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};
box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};
box.ondblclick=()=>{tx=ty=0;scale=1;apply();};
}
// ---- selection controls + add-to-library / add-project modal ----
document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};
// a <select> of the user's libraries; if the current value isn't among them
// (e.g. a project-only lib) it's added so nothing is silently dropped.
function libSelect(libs,sel,i,k){
const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}</option>`:'';
return `<select class="libsel" data-i="${i}" data-k="${k}">${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}</select>`;
}
function previewTable(plan,editable){
// project-only: green = being added, white/keep = already in the project library
const st=p=>!projChecked?'':((projDiff.add&&projDiff.add.includes(p.slug))?'addcell':((projDiff.keep&&projDiff.keep.includes(p.slug))?'keepcell':''));
const cell=(p,i)=>editable
? `<td>${libSelect(userLibs.symLibs,p.symLib,i,'symLib')}</td><td>${libSelect(userLibs.fpLibs,p.fpLib,i,'fpLib')}</td>`
: `<td><span class="lib ${st(p)}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}">${p.fpLib}.pretty</span></td>`;
let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?` <span class="inlibtag">✓ ${inl.lib||'in your library'}</span>`:''}</td><td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');
// project-only: symbols in the library no longer on the board — flagged for deletion (red)
if(!editable && projChecked && projDiff.remove && projDiff.remove.length){
const lib=(plan[0]&&plan[0].symLib)||'library';
rows+=projDiff.remove.map(n=>`<tr class="rmrow"><td>${n} <span class="rmtag">flagged for deletion</span></td><td>—</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');
}
return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`;
}
// parts already in the user's libraries (slug → {by, lib}), from /api/in-library
let inLib={}, inLibLoading=false, libChecked=false;
function setLibStatus(){ const el=document.getElementById('modal-status'); if(!el)return;
if(inLibLoading){ el.className='loading'; el.textContent='Searching your libraries…'; }
else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done — '+(n?n+' already in your libraries':'none already present'); }
else { el.className=''; el.textContent=''; } }
function checkInLib(slugs,cb){ inLibLoading=true; libChecked=false; setLibStatus(); if(cb)cb();
// The library scan runs shell commands on the user's machine via the bridge; if it's
// still going after 10s the bridge likely lacks shell permissions — surface that.
const warn=setTimeout(()=>{ const el=document.getElementById('modal-status'); if(el&&inLibLoading){ el.className='loading'; el.innerHTML='Searching your libraries… <span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span>'; } },10000);
fetch('api/in-library?slugs='+encodeURIComponent(slugs.join(','))).then(r=>r.json())
.then(m=>{ clearTimeout(warn); inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })
.catch(()=>{ clearTimeout(warn); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); }); }
// project-only mode: diff the board against the project library on disk (add/keep/remove)
let projDiff={}, projLoading=false, projChecked=false;
function setProjStatus(){ const el=document.getElementById('modal-status'); if(!el)return;
if(projLoading){ el.className='loading'; el.textContent='Checking your project library…'; }
else if(projChecked){ const a=(projDiff.add||[]).length, r=(projDiff.remove||[]).length;
el.className='done'; el.textContent = projDiff.exists ? `✓ Existing project library found — ${a} to add, ${r} to remove` : `No project library on disk yet — ${a} will be created`; }
else { el.className=''; el.textContent=''; } }
function checkProjectDiff(board,dest,cb){ if(!board||!dest){projDiff={};projChecked=false;setProjStatus();if(cb)cb();return;}
projLoading=true; projChecked=false; setProjStatus(); if(cb)cb();
fetch('api/project-diff?board='+encodeURIComponent(board)+'&dest='+encodeURIComponent(dest))
.then(r=>r.json()).then(d=>{projDiff=d||{};projLoading=false;projChecked=true;setProjStatus();if(cb)cb();})
.catch(()=>{projLoading=false;projChecked=true;setProjStatus();if(cb)cb();}); }
// wire the per-component library dropdowns to update the live plan
function wireLibSels(){document.querySelectorAll('#modal-preview .libsel').forEach(s=>s.onchange=()=>{if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;});}
function buildPlan(slugs,projectLib,projName){
return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);
return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:fpLibFor(c)};});
}
let modalGo=null;
const showModal=()=>document.getElementById('modal').style.display='flex';
const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();};
document.getElementById('modal-cancel').onclick=hideModal;
// click the dim backdrop (outside the box) to close, same as Cancel
document.getElementById('modal').addEventListener('click',e=>{ if(e.target.id==='modal')hideModal(); });
document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();};
function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});}
// recommended destination parents for delivered projects (editable in the box)
function openAddLib(){
const slugs=[...selected]; if(!slugs.length)return;
curPlan=buildPlan(slugs,false);
document.getElementById('modal-dest').innerHTML='';
document.getElementById('modal-title').textContent='Add to Library';
document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → pick which of your libraries each goes into`;
document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;
const go=document.getElementById('modal-go');
inLib={};
const drawP=()=>{document.getElementById('modal-preview').innerHTML=previewTable(curPlan,true);wireLibSels();};
selOpt('eda','eda',v=>{if(v==='altium'){document.getElementById('modal-preview').innerHTML='<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>';go.disabled=true;}else{drawP();go.disabled=false;}});
drawP(); checkInLib(curPlan.map(p=>p.slug),drawP);
go.disabled=false;go.textContent='Yes, add it';
modalGo=()=>doApply(curPlan,'kicad',false);
showModal();
}
let fbPrefetch=null;
function openAddProj(){
// prefetch the home folder listing FIRST (before the modal's library scan hogs the
// bridge) so clicking "Browse files" opens the folder browser instantly
fbPrefetch=fetch('api/list-dirs?p=').then(r=>r.json()).catch(()=>null);
const slugs=curDeps.slice();
const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_');
let mode='personal';
document.getElementById('modal-title').textContent='Add Project to my Computer';
document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;
document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;
// deliver-to: two boxes — an existing parent DIRECTORY (with path autosuggest)
// and the project folder NAME — joined by the OS separator into the full path.
// deliver-to: a "Browse files" button pops a native folder dialog on the user's
// machine (via the bridge); once picked it shows the path. Then / + project name.
document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><button type="button" id="destbrowse" class="browsebtn" title="choose a folder on your computer">Browse files</button><span class="destsep">${env.sep}</span><input id="destname" spellcheck="false" autocomplete="off" placeholder="project folder name"></div>`;
const destname=document.getElementById('destname'); destname.value=boardSlug;
let chosenDir=''; const browse=document.getElementById('destbrowse');
const destPath=()=>chosenDir?chosenDir.replace(/[\/\\]+$/,'')+env.sep+destname.value.trim():'';
// project directory to diff against: the browsed folder, else the default Projects dir, + the name
const projPath=()=>((chosenDir||destFolders()[0]||'').replace(/[\/\\]+$/,''))+env.sep+(destname.value.trim()||boardSlug);
const draw=()=>{
const editable=mode==='personal';
curPlan=buildPlan(slugs,mode==='project',projName);
if(editable)curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}});
document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> project library is created/updated — <span style="color:#4ade80">green</span> = added, white = already in the library, <span style="color:#f87171">red</span> = removed.':'Pick which of your libraries each symbol + footprint goes into. Parts already in your libraries are marked; keep the library to skip, or change it to add a copy there.'}</div>`+previewTable(curPlan,editable);
if(editable)wireLibSels();
modalGo=()=>doApply(curPlan,'kicad',mode==='project',boardSlug,destPath());
};
// run the check for the current mode (drives its status bar + coloring)
const runCheck=()=>{ if(mode==='project'){ inLib={}; checkProjectDiff(boardSlug,projPath(),draw); } else { projDiff={}; checkInLib(slugs,draw); } };
// An in-app folder browser (native OS dialogs can't render from the detached
// bridge process, and a browser file input can't yield the absolute path delivery
// needs). Navigates the user's filesystem over the bridge; the path is real.
browse.onclick=()=>openFolderBrowser(browse.dataset.path||'', path=>{
chosenDir=path; browse.dataset.path=path; browse.textContent=path; browse.title=path;
if(mode==='project')runCheck();
});
selOpt('mode','mode',v=>{mode=v;runCheck();});
document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';
inLib={}; projDiff={}; draw(); runCheck(); showModal();
}
async function doApply(plan,eda,project,board,dest){
const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';
const opts=document.getElementById('modal-opts');
const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));
if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}
// live status bar above the preview
let bar=document.getElementById('applybar');
if(!bar){bar=document.createElement('div');bar.id='applybar';bar.className='note';opts.parentNode.insertBefore(bar,opts.nextSibling);}
const poll=setInterval(async()=>{
let p; try{p=await fetch('api/apply-progress').then(r=>r.json());}catch(e){return;}
const pct=p.total?` · ${p.current}/${p.total}`:'';
go.textContent=(p.done?'✓ ':'')+(p.stage||'Working…').replace(/…$/,'').slice(0,22)+'…';
bar.innerHTML=`<b>${p.stage||'Working…'}</b>${pct}`;
if(p.done){
clearInterval(poll);
const r=p.result||{}, pf=(r.project&&r.project.files||[]);
go.textContent=`✓ Added ${r.count||0}`;
const folder=(r.project&&r.project.folder||'').replace(env.home||'~','~');
const sk=(r.skipped||[]);
bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`+
(sk.length?`<br><span style="color:#8b93a3">Skipped ${sk.length} already in your libraries (e.g. ${sk.slice(0,2).map(s=>s.component+' · '+s.by).join(', ')})</span>`:'')+
(pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')+
((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');
setTimeout(hideModal,pf.length||sk.length||(r.errors&&r.errors.length)?4200:1600);
}
},350);
}
document.getElementById('btn-addlib').onclick=openAddLib;
document.getElementById('btn-addproj').onclick=openAddProj;
document.getElementById('q').oninput=render;
document.getElementById('refresh').onclick=()=>loadList(true);
loadList();
</script>
</body>
</html>