master
John Lauer v0.14.0: wire the Send-to-AI pass-through the module needs (/updater/status and /ai-threads/run-prompt forwarded to ah'… 35ea651 1mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257
<!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>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>
<style>
  :root{--bg:#0d1117;--panel:#161b22;--elevated:#1c2128;--overlay:#21262d;--edge:#30363d;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b0;--acc2:#00e6dc;--green:#3fb950;--red:#f85149;--yellow:#d29922}
  *{box-sizing:border-box}
  body{margin:0;font:14px/1.5 'Satoshi',-apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}
  /* canonical Adom 44px three-zone app header (brand · subject · actions) */
  header.app{height:44px;flex:0 0 44px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;
             gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--edge);position:relative;z-index:30}
  header.app .brand{display:flex;align-items:center;gap:8px;min-width:0;position:relative;cursor:default}
  header.app .brand svg{width:16px;height:16px;color:var(--ink);flex:0 0 16px}
  header.app .app-name{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:13px;color:var(--acc);white-space:nowrap}
  header.app .app-sub{color:var(--mut);font-size:12px;white-space:nowrap}
  header.app .app-sub::before{content:"·";margin:0 6px;color:var(--mut)}
  header.app .brand{padding-right:14px;border-right:1px solid var(--edge)}
  header.app .subject{justify-self:center;display:flex;align-items:baseline;gap:10px;min-width:0}
  header.app .subject-name{font-weight:600;font-size:13px;max-width:44vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  header.app .subject-meta{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11px;color:var(--mut);white-space:nowrap}
  header.app .actions{justify-self:end;display:flex;align-items:center;gap:8px}
  /* app-info popover on brand hover */
  header.app .appinfo{position:absolute;top:calc(100% + 6px);left:0;min-width:220px;background:var(--overlay);border:1px solid var(--edge);
                      border-radius:10px;box-shadow:0 12px 34px #000a;padding:12px 14px;font-size:12px;opacity:0;visibility:hidden;
                      transition:opacity .18s ease;pointer-events:none;z-index:40}
  header.app .brand:hover .appinfo{opacity:1;visibility:visible;pointer-events:auto;transition-delay:.6s}
  header.app .appinfo .ai-row{display:flex;justify-content:space-between;gap:16px;padding:2px 0}
  header.app .appinfo .ai-k{color:var(--mut)}
  header.app .appinfo .ai-v{font-family:'JetBrains Mono',ui-monospace,monospace}
  header.app .appinfo a{color:var(--acc);text-decoration:none}
  header.app .appinfo a:hover{text-decoration:underline}
  #workspace{flex:1;min-height:0;display:flex}
  /* ── HOME gallery: the full-screen board wall (top level). The left list + view
       only render once you click into a project. ── */
  #home{flex:1;min-height:0;display:none;flex-direction:column;background:var(--bg)}
  #workspace.home #home{display:flex}
  #workspace.home #list,#workspace.home #view{display:none}
  .home-bar{display:flex;align-items:center;gap:16px;padding:16px 16px 12px;flex-wrap:wrap;width:100%}
  .seg{display:inline-flex;background:var(--elevated);border:1px solid var(--edge);border-radius:9px;padding:3px;gap:2px}
  .seg .segbtn{background:none;border:none;color:var(--mut);font:inherit;font-size:12.5px;font-weight:500;padding:6px 15px;border-radius:6px;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:7px;transition:color .12s,background .12s}
  .seg .segbtn:hover{color:var(--ink)}
  .seg .segbtn.active{background:var(--acc);color:#052220;font-weight:600}
  .seg .segn{font-size:11px;opacity:.75;font-variant-numeric:tabular-nums}
  .seg .segbtn.active .segn{opacity:.85}
  .home-tools{margin-left:auto;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
  .home-search-wrap{position:relative;display:flex;align-items:center}
  .home-search-wrap .hs-ico{position:absolute;left:11px;color:var(--mut);pointer-events:none}
  .home-search{background:#0d0f14;border:1px solid var(--edge);border-radius:9px;color:var(--ink);padding:8px 12px 8px 31px;font:inherit;font-size:13px;width:230px;transition:border-color .15s,width .18s}
  .home-search::placeholder{color:var(--mut)}
  .home-search:focus{outline:none;border-color:var(--acc);width:266px}
  .home-sort{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:7px;white-space:nowrap}
  .home-sort select{background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:8px;font-size:12px;padding:7px 26px 7px 10px;font-family:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%238b93a3' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 9px center}
  .home-sort select:focus{outline:none;border-color:var(--acc)}
  .home-chips{display:flex;flex-wrap:wrap;gap:6px;padding:0 16px 12px;width:100%}
  /* Clear breathing room between the filter chips and the first row/section header. */
  #home-scroll{flex:1;min-height:0;overflow:auto;padding:14px 16px 34px}
  /* Fixed 3-wide wall. A centered max-width keeps cards a consistent (bigger) size once
     the window is wide enough — past ~1240px the grid stops growing instead of stretching.
     Narrower viewports gracefully drop to 2 then 1 column. */
  .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:28px;align-content:start}
  @media (max-width:920px){ .gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px} }
  @media (max-width:600px){ .gallery{grid-template-columns:1fr;gap:18px} }
  .g-sec-head{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin:22px 2px 3px;font-size:12px;font-weight:700;color:var(--text-2,#8b949e);text-transform:uppercase;letter-spacing:.06em}
  .g-sec-head:first-child{margin-top:8px}
  .g-sec-head .gsh-avatar{width:22px;height:22px;border-radius:6px;object-fit:cover;background:#1a1f2b}
  .g-sec-head .gsh-count{color:#5b6472;font-weight:600}
  .g-sec-head .gsh-main{display:inline-flex;align-items:center;gap:10px;min-width:0}
  .g-sec-head .gsh-main.link{cursor:pointer}
  .g-sec-head .gsh-main.link:hover{color:var(--ink)}
  .g-sec-head .gsh-main.link:hover .gsh-label{color:var(--acc);text-decoration:underline}
  .g-sec-head .gsh-chev{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;background:none;border:none;border-radius:5px;cursor:pointer;color:var(--mut);transition:transform .15s ease,background .12s,color .12s}
  .g-sec-head .gsh-chev:hover{background:var(--elevated);color:var(--acc)}
  .g-sec-head .gsh-chev.coll{transform:rotate(-90deg)}
  .g-sec-head .gsh-eng{margin-left:6px;font-size:11px;font-weight:700;color:#f0a81c;background:#463307;border-radius:5px;padding:1px 7px;letter-spacing:0;font-variant-numeric:tabular-nums}
  .gcard{position:relative;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;cursor:pointer;display:flex;flex-direction:column;transition:border-color .15s,transform .15s,box-shadow .15s}
  .gcard::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--kc,#79b8ff);z-index:3;opacity:.9}
  .gcard:hover{transform:translateY(-3px);border-color:var(--kc,var(--acc));box-shadow:0 14px 32px #0009}
  .gcard .g-thumb{position:relative;aspect-ratio:16/10;background:#0d0f14;overflow:hidden}
  .gcard .g-hero{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover;transition:transform .35s ease;will-change:transform}
  .gcard:hover .g-hero{transform:scale(1.07)}
  .gcard .g-ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'Familjen Grotesk',sans-serif;font-weight:800;font-size:40px;color:#ffffff26;background:linear-gradient(140deg,color-mix(in srgb,var(--kc,#2a3340) 32%,#0d0f14) 0%,#0d0f14 72%)}
  .gcard .g-star{position:absolute;top:8px;right:9px;z-index:4;color:#f5c518;font-size:14px;filter:drop-shadow(0 1px 2px #000c)}
  .gcard .g-body{padding:11px 13px 12px;display:flex;flex-direction:column;gap:8px;flex:1;min-width:0}
  .gcard .g-title{font-weight:600;font-size:13.5px;line-height:1.3;color:var(--ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
  .gcard .g-meta{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
  .gcard .tag{display:inline-block;font-size:10px;border-radius:4px;padding:2px 6px;font-weight:600;line-height:1.3}
  .gcard .g-sub{display:flex;align-items:flex-end;gap:8px;margin-top:auto}
  .gcard .g-owner{font-size:11px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1}
  .gcard .g-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex:none}
  .gcard .g-eng{font-size:11px;font-weight:700;color:#f0a81c;font-variant-numeric:tabular-nums;line-height:1.1}
  .gcard .g-owner.link{cursor:pointer}
  .gcard .g-owner.link:hover{color:var(--acc);text-decoration:underline}
  .g-userbanner{align-items:center}
  .g-userbanner svg{color:var(--acc)}
  .g-userbanner .g-clearuser{margin-left:8px;background:var(--elevated);border:1px solid var(--edge);color:var(--mut);width:22px;height:22px;border-radius:6px;cursor:pointer;font-size:12px;line-height:1;display:inline-flex;align-items:center;justify-content:center;padding:0}
  .g-userbanner .g-clearuser:hover{border-color:var(--acc);color:var(--ink)}
  .gcard .g-when{font-size:10.5px;color:#6d7686;white-space:nowrap;flex:none;font-variant-numeric:tabular-nums}
  .g-empty{grid-column:1/-1;color:#8b93a3;text-align:center;padding:64px 20px;line-height:1.7}
  @media (max-width:600px){ .home-bar{padding:12px 16px 8px} .home-tools{width:100%;margin-left:0} .home-search,.home-search:focus{width:100%;flex:1} .home-chips,#home-scroll{padding-left:16px;padding-right:16px} }
  /* ── Tools menu (top-right): Chip Fetcher + Library Manager(EDA) ── */
  .tools-wrap{position:relative}
  /* "update available" badge — hidden until the update check confirms a newer wiki version */
  #update-badge{display:none;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:7px;font-size:12px;font-weight:600;text-decoration:none;background:#3a2e12;color:#e5b567;border:1px solid #6b501a;white-space:nowrap}
  #update-badge.show{display:inline-flex}
  #update-badge:hover{border-color:#d29922;color:#f0c674;background:#463813}
  #tools-btn{display:flex;align-items:center;gap:6px;background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:7px;height:26px;padding:0 10px;font-size:12px;cursor:pointer;font-family:inherit}
  #tools-btn:hover,#tools-btn.open{border-color:var(--acc);color:var(--acc)}
  #tools-menu{position:absolute;top:calc(100% + 6px);right:0;min-width:186px;background:var(--overlay);border:1px solid var(--edge);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;display:none;z-index:60}
  #tools-menu.open{display:block}
  .menu-item{display:flex;align-items:center;gap:9px;width:100%;background:none;border:none;color:var(--ink);font:inherit;font-size:13px;text-align:left;padding:7px 9px;border-radius:7px;cursor:pointer;white-space:nowrap}
  .menu-item:hover:not(:disabled){background:var(--elevated)}
  .menu-item svg{flex:0 0 15px;color:var(--mut)}
  .menu-item:hover:not(:disabled) svg{color:var(--acc)}
  .menu-item .chev{margin-left:auto}
  .menu-sub{position:relative}
  .menu-flyout{position:absolute;top:-5px;right:calc(100% + 4px);min-width:150px;background:var(--overlay);border:1px solid var(--edge);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;display:none}
  .menu-sub:hover .menu-flyout,.menu-flyout.open{display:block}
  .menu-item.soon{cursor:default;color:var(--mut)}
  .menu-item.soon em{margin-left:auto;font-style:normal;font-size:10px;color:var(--yellow);background:#2a2109;border:1px solid #4a380e;border-radius:5px;padding:1px 6px}
  /* full-screen embedded-tool view */
  #tool-view{position:fixed;inset:0;z-index:200;background:var(--bg);display:none;flex-direction:column}
  #tool-view.open{display:flex}
  #tool-view .tv-head{height:44px;flex:0 0 44px;display:flex;align-items:center;gap:10px;padding:0 14px;background:var(--panel);border-bottom:1px solid var(--edge)}
  #tool-view .tv-back{display:flex;align-items:center;gap:7px;background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:7px;height:28px;padding:0 11px;font:inherit;font-size:12px;cursor:pointer}
  #tool-view .tv-back:hover{border-color:var(--acc);color:var(--acc)}
  #tool-view .tv-title{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:13px;color:var(--acc)}
  #tool-view .tv-sub{color:var(--mut);font-size:12px}
  #tool-view .tv-body{flex:1;min-height:0;position:relative}
  #tool-view iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
  #tool-view .tv-load{position:absolute;inset:0;display:grid;place-items:center;color:var(--mut);font-size:13px;gap:10px}
  /* toasts: transient feedback (bottom-center), also the AI's live narration channel */
  #toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3000;display:flex;flex-direction:column-reverse;gap:8px;align-items:center;pointer-events:none}
  .toast{pointer-events:auto;min-width:220px;max-width:min(440px,90vw);display:flex;align-items:center;gap:10px;padding:10px 13px;border-radius:10px;
         background:var(--overlay);border:1px solid var(--edge);color:var(--ink);font-size:13px;box-shadow:0 10px 30px #000a;cursor:pointer;
         animation:toastin .18s ease}
  @keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
  .toast.out{opacity:0;transform:translateY(8px);transition:opacity .2s ease,transform .2s ease}
  .toast .ti{flex:0 0 16px;width:16px;height:16px}
  .toast.success .ti{color:var(--green)} .toast.success{border-color:#2c5a3a}
  .toast.error .ti{color:var(--red)} .toast.error{border-color:#5a2c2c}
  .toast.info .ti{color:var(--acc)}
  .toast .tmsg{flex:1;min-width:0}
  #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}
  #list h1{font-family:'Familjen Grotesk',-apple-system,sans-serif;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{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:#0c2b29;border-color:var(--acc)}
  #crumb{padding:10px 16px 0;font-size:12px;color:var(--mut)}
  #crumb:empty{display:none}
  #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:#0c2b29;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}
  .tag{display:inline-block;font-size:10px;background:#0c2b29;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.circuit{background:#3d1a2e;color:#f472b6}
  .tag.kind.library{background:#2b2350;color:#b9a4ff}
  .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}
  .sortrow{display:flex;justify-content:flex-end;align-items:center;padding:0 16px 8px}
  .sortrow label{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--mut)}
  .sortrow select{background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:6px;font-size:11px;padding:3px 22px 3px 7px;font-family:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%238b93a3' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 7px center}
  .sortrow select:hover{border-color:var(--acc)}
  /* board-specific library expander on a board row */
  /* subtle disclosure on its OWN line below the tags — not a loud pill inline with
     them; scoped under .row so it always beats the public-page button rule */
  .row button.libtoggle{margin-top:7px;display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:500;color:var(--mut);background:transparent;border:1px solid var(--edge);border-radius:6px;padding:3px 9px;cursor:pointer;font-family:inherit;line-height:1.2}
  .row button.libtoggle:hover,.row button.libtoggle.open{border-color:var(--acc);color:var(--ink);background:transparent;filter:none}
  .row button.libtoggle .cv{display:inline-block;transition:transform .15s;font-size:8px;line-height:1}
  .row button.libtoggle.open .cv{transform:rotate(180deg)}
  .libchild{background:#0d0f14;border-bottom:1px solid var(--edge)}
  .libchild-row{padding:8px 16px 8px 30px;border-bottom:1px solid var(--edge);cursor:pointer}
  .libchild-row:last-child{border-bottom:none}
  .libchild-row:hover{background:var(--elevated)}
  .libchild-row .mpn{font-weight:600;font-size:13px}
  .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.circuit{background:#3d1a2e;color:#f472b6}
  .fchip.on.library{background:#2b2350;color:#b9a4ff}
  .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}
  /* Right-justified two-line block so a long org name (e.g. "ntx embedded") no
     longer crowds the score badge off the row; mirrors the trending board rows. */
  .orgrow .orgtext{margin-left:auto;min-width:0;text-align:right}
  .orgrow .orgtop{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}
  .orgrow .orgname{font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .orgrow .orgmeta{color:var(--mut);font-size:12.5px;margin-top:2px}
  .orgrow .scorebadge{margin-left:0;flex:none}
  .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 .pubrow button{background:var(--acc);color:#052220;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 .pubrow 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:#123b39}
  #toolbar button.primary{background:var(--acc);color:#052220;border-color:var(--acc);font-weight:600}
  #toolbar button:disabled{opacity:.45;cursor:not-allowed}
  /* unavailable toolbar buttons: greyed like a disabled button but still hoverable so
     the Adom tooltip (#tip via data-tip) can explain why. */
  #toolbar button.off{opacity:.45;cursor:not-allowed}
  #toolbar button.off:hover{border-color:var(--edge);background:#1a2130}
  .bomrow .bom-qty{color:var(--acc);font-weight:700;margin-right:6px}
  .bomrow .bom-refs{color:var(--mut);font-weight:400;font-size:11px;font-family:'JetBrains Mono',ui-monospace,monospace}
  .bomrow.nolink{cursor:default}
  .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-family:'Familjen Grotesk',-apple-system,sans-serif;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}
  /* wide: the plan table carries five columns (component, symbol name, type, and
     two library pickers) and was cramped at the old 760px */
  .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(1180px,95vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}
  .symname{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;width:100%;min-width:150px}
  .symname:focus{outline:none;border-color:var(--acc)}
  .symname.edited{border-color:var(--acc);color:var(--acc)}
  .modal h2{font-family:'Familjen Grotesk',-apple-system,sans-serif;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:#0e2e2c}
  .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:#052220;border-color:var(--acc);font-weight:700}
  .modal .lib{font:12px 'JetBrains Mono',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 'JetBrains Mono',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 'JetBrains Mono',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-ico{vertical-align:-2px;margin-right:5px;color:var(--mut)}
  .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:#052220!important;font-weight:600}
  #modal-dest #destname{flex:1}
  #modal-dest .destsep{color:var(--mut);font:14px 'JetBrains Mono',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)}
  /* a row still needing a choice (amber) vs no libraries detected at all (dimmed) */
  .libsel.unset{border-color:#f0a81c;color:#f0a81c}
  .libsel.nolibs{opacity:.6;font-style:italic}
  /* KiCad's own bundled library, not one of the user's — blue, so it reads as
     "this came with KiCad" rather than the amber "you still have to choose" */
  .libsel.stock{border-color:#5aa9e6;color:#9ad0f5;background:#5aa9e614}
  .lib.stock{border-color:#5aa9e6;color:#9ad0f5;background:#5aa9e614}
  .stocktag{color:#9ad0f5}
  .stocktag::after{content:' (KiCad)';opacity:.75;font-size:10px}
  .bulkset{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin:0 0 8px;padding:7px 10px;border:1px solid var(--edge);border-radius:8px;background:#262c3688;font-size:12px;color:var(--mut)}
  .bulkitem{display:inline-flex;gap:6px;align-items:center}
  .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 'JetBrains Mono',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:#0c2b29;border-left:3px solid var(--acc);padding-left:13px}
  .row .mpn{font-weight:600}
  .row .sub{color:var(--mut);font-size:12px;font-family:'JetBrains Mono','JetBrains Mono',ui-monospace,monospace}
  #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:#123b39}
  #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}
  /* empty component-dependency list is an ERROR the maintainer should fix (its BOM
     and library can't resolve), so it reads as a red bar, not a muted gray note */
  .deps-none{background:#3a1214;border:1px solid #7f1d1d;color:#fca5a5;border-radius:8px;padding:10px 13px;font-size:13px;line-height:1.45;font-weight:500;cursor:default}
  .deps-none b{color:#fecaca}
  .bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)}
  .bo-head .title{font-family:'Familjen Grotesk',-apple-system,sans-serif;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:#0c2b29}
  .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}
  /* 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:'JetBrains Mono',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 'JetBrains Mono',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 'JetBrains Mono',ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}
  #tip b{color:var(--acc)}
  .bo-embed{width:100%;height:100%;border:0;display:block}
  /* ══ adom-ui-design 1.22.1 dock-dashboard contract ══════════════════════════
     Spec token names aliased onto this app's existing palette so the numbers
     below can be copied from the skills verbatim rather than re-derived. */
  :root{--text:var(--ink);--muted:var(--mut);--line:var(--edge);--surface:var(--panel);
        --teal:var(--acc);--on-accent:#052220}

  /* dash-header: the name is TEXT, not branding (15px var(--text), never teal,
     never the slug), and the mark is 18px/1.8 — the same file as the favicon. */
  header.app .app-name{font-size:15px;color:var(--text);letter-spacing:-.01em}
  header.app .brand svg{width:18px;height:18px;flex:0 0 18px;stroke-width:1.8}
  header.app .app-sub{font-size:13px}
  @media (max-width:720px){ header.app .app-sub{display:none} }
  /* Right-hand controls: target chip → ? → cog, same order on every Adom app. */
  .hdr-target{display:inline-flex;align-items:center;gap:7px;font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap}
  .hdr-target b{color:var(--text);font-weight:500}
  .hdr-target .led{width:8px;height:8px;border-radius:50%;background:var(--green);flex:0 0 8px}
  .hdr-target .led.warn{background:var(--yellow)}
  .ghost-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;
    background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;padding:0;transition:color .12s,background .12s,border-color .12s}
  .ghost-btn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}
  .ghost-btn:focus-visible{outline:2px solid var(--teal);outline-offset:1px}
  .ghost-btn svg{width:18px;height:18px}
  /* A toggle that looks identical in both states is a lie (input-feedback). */
  .ghost-btn[aria-pressed="true"]{color:var(--teal);background:rgba(0,184,177,.12);border-color:var(--line)}

  /* The newbie panel is the SHARED module (adom-ui-design assets/module/newbie-panel.js),
     mounted at #newbie and served from /newbie-panel.js. It owns the card, the grid and
     its collapse, the table, both icons, the copy and send paths, its toasts and tooltips,
     the X and the header toggle. Hand-rolled panel CSS was deleted rather than left beside
     it: seven apps building this from the written spec produced seven different panels. */
  /* status-leds: ONE compact pill row. The SERVER emits each pill's short state;
     this page never slices prose to make one. Prose appears on tap only, and a red
     pill always carries its next step. */
  :root{--led-green:var(--green);--led-amber:var(--yellow);--led-red:var(--red)}
  #dash-top{flex:0 0 auto;padding:0 16px}
  #dash-top:empty{display:none}
  #leds{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}
  #leds:empty{display:none}
  .led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);
    border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text)}
  .led-pill:hover{border-color:var(--teal)}
  .led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:0 0 8px}
  .led-pill[data-state=ok] .dot{background:var(--led-green)}
  .led-pill[data-state=busy] .dot{background:var(--led-amber)}
  .led-pill[data-state=bad] .dot{background:var(--led-red)}
  .led-pill .lbl{color:var(--muted)}
  .led-detail{margin:0 0 16px;background:var(--surface);border:1px solid var(--line);
    border-left:3px solid var(--teal);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text);max-width:70ch}
  .led-detail[hidden]{display:none}
  .led-detail .nx{display:block;margin-top:7px;color:var(--muted)}
</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>
  <header class="app">
    <div class="brand">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2.1" fill="currentColor" stroke="none"/><ellipse cx="12" cy="12" rx="10" ry="4.3"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(120 12 12)"/></svg>
      <span class="app-name">Project Manager</span><span class="app-sub">Adom&#39;s Electronics Development Suite</span>
      <div class="appinfo">
        <div class="ai-row"><span class="ai-k">version</span><span class="ai-v" id="ai-ver">-</span></div>
        <div class="ai-row"><span class="ai-k">built on</span><span class="ai-v">adom-lbr</span></div>
        <div class="ai-row"><span class="ai-k">source</span><a href="https://wiki.adom.inc/adom/adom-project-manager" target="_blank" rel="noopener">wiki page ↗</a></div>
      </div>
    </div>    <div class="subject"><span class="subject-name" id="hdr-name"></span><span class="subject-meta" id="hdr-meta"></span></div>
    <div class="actions">
      <a id="update-badge" href="https://wiki.adom.inc/adom/adom-project-manager" target="_blank" rel="noopener" title="A newer version is on the wiki">
        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
        <span id="update-badge-txt">Update available</span>
      </a>
      <div class="tools-wrap">
        <button id="tools-btn" title="Tools — Chip Fetcher Lite, Library Manager">
          <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6z"/></svg>
          <span>Tools</span>
        </button>
        <div id="tools-menu" class="menu">
          <button class="menu-item" data-tool="cfl">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="7" rx="1"/><path d="M9 10v3M15 10v3M6 13h12l-1 8H7z"/></svg>
            <span>Chip Fetcher Lite</span>
          </button>
          <div class="menu-sub">
            <button class="menu-item" id="lm-parent">
              <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h6l2 2h8v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/></svg>
              <span>Library Manager</span>
              <svg class="chev" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>
            </button>
            <div class="menu-flyout">
              <button class="menu-item" data-tool="klm"><span>KiCad</span></button>
              <button class="menu-item soon" disabled><span>Altium</span><em>coming soon</em></button>
              <button class="menu-item soon" disabled><span>Fusion</span><em>coming soon</em></button>
            </div>
          </div>
        </div>
      </div>
      <button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button>
      <span class="hdr-target" id="hdr-target" title="Which machine the bridge answers on"><span class="led" id="hdr-led"></span>target:&nbsp;<b id="hdr-box">this machine</b></span>
      <button class="ghost-btn" id="nb-toggle">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 0 1 4.9.6c0 1.7-2.5 2.2-2.5 3.7"/><path d="M12 17h.01"/></svg>
      </button>
      <button class="ghost-btn" id="cog-btn" data-tooltip="Settings" aria-label="Settings">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
      </button>
    </div>
  </header>
  <div id="toasts"></div>
  <!-- full-screen embedded tool (Project Manager launches the Node tool + reverse-proxies it) -->
  <div id="tool-view">
    <div class="tv-head">
      <button class="tv-back" onclick="closeTool()"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>Project Manager</button>
      <span class="tv-title" id="tv-title"></span><span class="tv-sub" id="tv-sub"></span>
    </div>
    <div class="tv-body"><div class="tv-load" id="tv-load"><img src="loader.gif" width="48" alt=""><span>Starting tool…</span></div><iframe id="tv-frame" title="tool"></iframe></div>
  </div>
  <div id="dash-top">
  <div id="newbie"></div>
  <div id="leds" role="status" aria-label="System status"></div>
  <div class="led-detail" id="led-detail" hidden></div>
  </div>

  <div id="workspace">
  <!-- HOME: full-screen gallery of boards/molecules/projects/circuits. The left list
       only appears once you click into a project (the interior below). -->
  <section id="home">
    <div class="home-bar">
      <div id="home-tabs" class="seg"></div>
      <div class="home-tools">
        <div class="home-search-wrap"><svg class="hs-ico" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></svg><input id="home-q" class="home-search" placeholder="search projects…"></div>
        <label class="home-sort">Sort <select id="home-sort-sel">
          <option value="recent">Most recent</option>
          <option value="alpha">Alphabetical</option>
          <option value="tag">Tag</option>
          <option value="eda">EDA</option>
          <option value="score">Engagement</option>
        </select></label>
      </div>
    </div>
    <div id="home-chips" class="tagfilter home-chips"></div>
    <div id="home-scroll"><div id="home-grid" class="gallery"></div></div>
  </section>
  <aside id="list">
    <div id="crumb"></div>
    <div id="tabbar" class="tabs"></div>
    <input id="q" placeholder="filter boards…">
    <div id="tagfilter" class="tagfilter"></div>
    <div id="sortrow" class="sortrow"><label>Sort <select id="sortby">
      <option value="alpha">Alphabetical</option>
      <option value="tag">Tag</option>
      <option value="eda">EDA</option>
      <option value="score">Engagement</option>
    </select></label></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" class="off">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>
  <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 src="newbie-panel.js"></script>
<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=[], curBom=[], me='', pubQuery='', orgs=[], boardTab='mine';
// canonical header subject zone: the currently-open board + its meta
function setSubject(name, slug){
  const n=document.getElementById('hdr-name'), m=document.getElementById('hdr-meta');
  if(n) n.textContent=name||'';
  if(m) m.textContent=slug&&slug!==name?slug:'';
}
function setSubjectMeta(txt){ const m=document.getElementById('hdr-meta'); if(m&&boardSlug) m.textContent=txt||''; }


// tooltips (600ms, viewport-clamped) for every [data-tooltip] control
(function(){
  let tip,timer;
  function show(el){
    if(!tip){ tip=document.createElement('div'); tip.id='tip'; document.body.appendChild(tip); }
    tip.textContent=el.getAttribute('data-tooltip')||''; tip.classList.add('on');
    const r=el.getBoundingClientRect(), t=tip.getBoundingClientRect();
    let left=r.left+r.width/2-t.width/2, top=r.bottom+8;
    left=Math.max(6,Math.min(left,innerWidth-t.width-6));
    if(top+t.height>innerHeight-6) top=r.top-t.height-8;
    tip.style.left=left+'px'; tip.style.top=top+'px';
  }
  function hide(){ clearTimeout(timer); if(tip) tip.classList.remove('on'); }
  document.addEventListener('mouseover',e=>{
    const el=e.target.closest && e.target.closest('[data-tooltip]');
    if(!el) return; clearTimeout(timer); timer=setTimeout(()=>show(el),600);
  });
  document.addEventListener('mouseout',e=>{ if(e.target.closest && e.target.closest('[data-tooltip]')) hide(); });
  document.addEventListener('click',hide,true);
})();

// ══ status-leds ══════════════════════════════════════════════════════════════
// Pills come from GET /api/status, which answers from cache in milliseconds and
// emits its OWN short state per pill. Tapping one reveals the prose; a bad pill
// shows its next step.
async function ledsRefresh(){
  let d; try{ d=await fetch('api/status').then(r=>r.json()); }catch(e){ return; }
  const row=document.getElementById('leds'), det=document.getElementById('led-detail');
  if(!row||!Array.isArray(d.pills)) return;
  const openId=row.getAttribute('data-open');
  row.innerHTML='';
  d.pills.forEach(p=>{
    const b=document.createElement('button');
    b.className='led-pill'; b.setAttribute('data-state',p.status||'ok');
    b.innerHTML='<span class="dot"></span><span class="lbl"></span>&nbsp;<span class="st"></span>';
    b.querySelector('.lbl').textContent=p.label;
    b.querySelector('.st').textContent=p.state;   // the SERVER's short state, never sliced here
    b.setAttribute('aria-label',p.label+': '+p.state);
    b.onclick=()=>{
      const same=row.getAttribute('data-open')===p.id;
      if(same){ row.removeAttribute('data-open'); det.hidden=true; return; }
      row.setAttribute('data-open',p.id);
      det.textContent=p.detail||'';
      if(p.next){ const n=document.createElement('span'); n.className='nx'; n.textContent='Next: '+p.next; det.appendChild(n); }
      det.hidden=false;
    };
    row.appendChild(b);
    if(openId===p.id){ det.textContent=p.detail||'';
      if(p.next){ const n=document.createElement('span'); n.className='nx'; n.textContent='Next: '+p.next; det.appendChild(n); }
      det.hidden=false; row.setAttribute('data-open',p.id); }
  });
}

// ══ newbie panel: MOUNT the shared module, never rebuild it ══════════════════
// We own exactly three things: our phrases, our noun, and what the demo does.
const SAY_PHRASES=[
  "open my boards in project manager",
  "add this board's parts to my kicad library with project manager",
  "send this whole project to my computer from project manager",
  "show me the layer stackup in project manager",
  "highlight this net in project manager's board view"
];
// Each region is guarded on its own. A throw in ANY one of them used to take the
// rest of the boot with it, which is how a page ends up HALF-BOOTED — some regions
// painted, others silently missing, and no error visible on the surface that
// served it. The boot marker at the end is the only reliable proof the script
// finished; never infer boot health from whether one region happens to be there.
function nbInit(){
  const fails=[];
  try{
    if(!window.AdomNewbiePanel) throw new Error('AdomNewbiePanel missing — /newbie-panel.js did not load');
    AdomNewbiePanel.mount({
      mount:'#newbie',
      app:'project manager',
      appName:'Adom Project Manager',
      slug:'adom-project-manager',
      toggleButton:'#nb-toggle',
      demo:{label:'Play the demo', meta:'a 2-minute tour',
            onPlay:function(){ toast('The guided demo is not recorded yet.','info'); }},
      phrases:SAY_PHRASES
    });
    window.__pmPanelMounted=true;
  }catch(e){ fails.push('panel: '+e.message); console.error('[project-manager] panel mount failed',e); }

  try{
    const cog=document.getElementById('cog-btn');
    if(cog) cog.onclick=()=>toast('Settings are coming to this dashboard.','info');
    // The target chip states the box honestly rather than guessing when the
    // container cannot learn its name.
    fetch('api/env').then(r=>r.json()).then(e=>{
      const box=document.getElementById('hdr-box'), led=document.getElementById('hdr-led');
      if(box&&e&&e.machine_name) box.textContent=e.machine_name;
      if(led&&e&&e.os==='linux'&&e.home==='/root') led.classList.add('warn');
    }).catch(()=>{});
  }catch(e){ fails.push('header: '+e.message); console.error('[project-manager] header wiring failed',e); }

  try{ ledsRefresh(); window.__pmLedTimer=setInterval(ledsRefresh,15000); }
  catch(e){ fails.push('leds: '+e.message); console.error('[project-manager] status leds failed',e); }

  // Set LAST, and record what failed, so a diagnosis never has to be inferred
  // from which regions happen to be painted.
  window.__pmBoot={complete:true,failed:fails,version:'0.14.0',
                   panelMounted:!!window.__pmPanelMounted,ledTimer:!!window.__pmLedTimer};
  if(fails.length) toast('Part of this dashboard failed to start: '+fails.join('; '),'error');
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',nbInit); else nbInit();

// ── toasts: every action answers back; also the AI's live narration channel ──
const TOAST_ICONS={
  success:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
  error:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
  info:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 8h.01M11 12h1v4h1"/><circle cx="12" cy="12" r="9"/></svg>'};
function toast(msg,type){
  type=type||'info';
  const box=document.getElementById('toasts'); if(!box||!msg)return;
  const el=document.createElement('div'); el.className='toast '+type;
  el.innerHTML=(TOAST_ICONS[type]||TOAST_ICONS.info)+'<span class="tmsg"></span>';
  el.querySelector('.tmsg').textContent=msg;
  while(box.children.length>=4) box.removeChild(box.firstChild); // cap visible
  box.appendChild(el);
  const kill=()=>{ el.classList.add('out'); setTimeout(()=>el.remove(),200); };
  el.onclick=kill;
  if(type!=='error') setTimeout(kill,4000); // errors stay until dismissed
}
// drain AI-pushed toasts (POST /ui/toast queues them server-side)
async function pollToasts(){
  try{ const q=await fetch('ui/toasts').then(r=>r.json());
    (q.toasts||[]).forEach(t=>toast(t.message,t.type)); }catch(e){}
  setTimeout(pollToasts,1500);
}
pollToasts();
// ── Tools menu: Project Manager launches each Node tool on demand and iframes it (served
// through Project Manager's own /tools/<slug>/ reverse proxy, so it stays on this origin). ──
const TOOL_META={ cfl:{title:'Chip Fetcher',sub:'wiki-first component sourcing'}, klm:{title:'Library Manager',sub:'KiCad libraries'} };
function toggleToolsMenu(open){ const m=document.getElementById('tools-menu'),b=document.getElementById('tools-btn'); if(!m)return; const on=open!==undefined?open:!m.classList.contains('open'); m.classList.toggle('open',on); b.classList.toggle('open',on); }
document.getElementById('tools-btn')?.addEventListener('click',(e)=>{ e.stopPropagation(); toggleToolsMenu(); });
document.addEventListener('click',(e)=>{ if(!e.target.closest('.tools-wrap')) toggleToolsMenu(false); });
document.querySelectorAll('#tools-menu .menu-item[data-tool]').forEach(el=>el.addEventListener('click',()=>{ toggleToolsMenu(false); openTool(el.getAttribute('data-tool')); }));
document.getElementById('sortby')?.addEventListener('change',(e)=>{ sortMode=e.target.value; if(!boardSlug&&!projectSlug)renderGallery(); });
// Home gallery toolbar: live search + sort. Sort default (most recent) is set on #sortMode.
document.getElementById('home-q')?.addEventListener('input',()=>{ if(!boardSlug&&!projectSlug)renderGallery(); });
document.getElementById('home-sort-sel')?.addEventListener('change',(e)=>{ sortMode=e.target.value; if(!boardSlug&&!projectSlug)renderGallery(); });
async function openTool(slug){
  const meta=TOOL_META[slug]||{title:slug,sub:''};
  document.getElementById('tv-title').textContent=meta.title;
  document.getElementById('tv-sub').textContent=meta.sub;
  const view=document.getElementById('tool-view'), frame=document.getElementById('tv-frame'), load=document.getElementById('tv-load');
  frame.src='about:blank'; load.style.display='grid'; view.classList.add('open');
  toast('Starting '+meta.title+'…','info');
  try{
    const r=await fetch('tools/launch/'+slug,{method:'POST'}).then(r=>r.json());
    if(!r.ok) throw new Error(r.error||'launch failed');
    frame.onload=()=>{ load.style.display='none'; };
    frame.src=r.base; // e.g. tools/klm/  (relative → served via Project Manager proxy)
    toast(meta.title+' ready','success');
  }catch(err){ load.innerHTML='<div style="color:var(--red)">Could not start '+meta.title+'.<br><span class="muted" style="font-size:12px">Ensure Node is installed and the tool package is present.</span></div>'; toast('Could not start '+meta.title,'error'); }
}
function closeTool(){ const view=document.getElementById('tool-view'), frame=document.getElementById('tv-frame'); view.classList.remove('open'); setTimeout(()=>{ frame.src='about:blank'; },200); }
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && document.getElementById('tool-view').classList.contains('open')) closeTool(); });
// Cross-probe relay: the schematic + PCB view iframes are same-origin; when a part
// is clicked in one it posts {adomXprobe, ref} up here, and we forward it to the
// OTHER view so it highlights + recenters the same reference (KiCad-style probe).
window.addEventListener('message', (e)=>{
  const d=e.data; if(!d || !d.adomXprobe || (!d.ref && !d.clear)) return; // select OR deselect
  document.querySelectorAll('.bo-embed').forEach((f)=>{ if(f.contentWindow && f.contentWindow!==e.source){ try{ f.contentWindow.postMessage(d,'*'); }catch(err){} } });
});
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')||t.includes('circuit');};
const isProject=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('project');
// A tagged "<Name> Library" page (what a board depends on) — list it alongside boards
// so libraries are browsable directly, not only reachable through their board.
const isLibrary=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('library');
// A board-specific library is named "<board-slug>-library" and its parent board/
// molecule/project is itself in the list. Those aren't standalone: we hide them from
// the top level and surface them under their parent via a "Library" expander on the
// board row. A library with no matching parent (e.g. adom-machine-parts-library) is
// standalone and lists on its own.
function parentBoardOf(c){
  if(!isLibrary(c)||!c.slug.endsWith('-library'))return null;
  const p=c.slug.slice(0,-'-library'.length), b=bySlug[p];
  return (b&&(isBoard(b)||isProject(b)))?p:null;
}
function librariesFor(slug){ return all.filter(c=>parentBoardOf(c)===slug); }
const isStandaloneLibrary=c=>isLibrary(c)&&!parentBoardOf(c);
const isListable=c=>isBoard(c)||isProject(c)||isStandaloneLibrary(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();
// A user library name reduced to its identifying token: "Wago_BLT" -> "wago",
// "JLC_Resistors_BLT" -> "jlc resistors". Used for MANUFACTURER matching below.
function libNameToken(name){return (name||'').replace(/[ _-]*BLT$/i,'').replace(/[_-]+/g,' ').trim().toLowerCase();}
// Match one of the user's libraries by MANUFACTURER. Personal footprint libraries are
// commonly named per manufacturer (Wago_BLT, Bosch_BLT, Microchip_BLT, TI_BLT), which
// no category keyword can ever match — without this every component fell through to
// libs[0] and was silently filed under whichever library happened to be first.
// Tokens shorter than 3 chars are skipped (a bare "ti"/"gct" would match inside
// unrelated words); the longest matching token wins so the most specific library does.
function libByManufacturer(libs,c){
  if(!c)return '';
  const hay=((c.mfr||'')+' '+(c.manufacturer||'')+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();
  if(!hay.trim())return '';
  let best='';
  for(const l of libs){
    const tok=libNameToken(l.name);
    if(tok.length<3)continue;
    const re=new RegExp('(^|[^a-z0-9])'+tok.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([^a-z0-9]|$)');
    if(re.test(hay)&&tok.length>libNameToken(best).length)best=l.name;
  }
  return best;
}
// Best guess among the user's discovered libraries: manufacturer-named library first,
// then a category-named one. NO libs[0] fallback — an unmatched component returns ''
// so the UI asks the user to pick, instead of silently delivering into a wrong library.
// A category keyword must start a WORD in the library name. A bare substring test
// silently mis-filed parts: 'osc' matches "B(osc)h", 'ic' matches "M(ic)rochip", so
// crystals landed in Bosch_BLT and every IC in Microchip_BLT.
function libByCategory(libs,type){
  for(const kw of (TYPE_KEYWORDS[type]||[])){
    const re=new RegExp('(^|[^a-z0-9])'+kw.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'));
    const x=libs.find(l=>re.test(l.name.toLowerCase()));
    if(x)return x.name;
  }
  return '';
}
function bestLib(libs,type,c){
  if(!libs||!libs.length)return '';
  return libByManufacturer(libs,c)||libByCategory(libs,type)||'';
}
function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c),c);}
function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c),c);}
// Load the user's KiCad libraries, POLLING until we actually HAVE libraries. An empty
// result is never latched: the machine can be detected while the shell probe that reads
// the lib tables still fails (bridge warming up, shell permission/reason gate), and
// treating that as "you have no libraries" left every dropdown permanently dead with no
// recovery short of a reload. Poll ~2min, then surface a retry instead of failing silent.
let libsGaveUp=false;
const libCount=()=>((userLibs.symLibs||[]).length+(userLibs.fpLibs||[]).length);
function loadLibraries(attempt){
  attempt=attempt||0;
  fetch('api/libraries').then(r=>r.json()).then(l=>{
    const n=l?((l.symLibs||[]).length+(l.fpLibs||[]).length):0;
    if(n){ userLibs=l; libsGaveUp=false; redrawLibSelects(); return; }   // only latch a REAL answer
    if(attempt<48) setTimeout(()=>loadLibraries(attempt+1), 2500);
    else { libsGaveUp=true; redrawLibSelects(); }
  }).catch(()=>{ if(attempt<48) setTimeout(()=>loadLibraries(attempt+1), 3000); else {libsGaveUp=true;redrawLibSelects();} });
}
// Libraries can land AFTER a modal is already open; repaint its table so the user
// doesn't sit looking at empty dropdowns that the app has since been able to fill.
function redrawLibSelects(){
  const m=document.getElementById('modal');
  if(!m||m.style.display!=='flex'||!curPlan.length)return;
  if(typeof curRedraw==='function')curRedraw();
}
let curRedraw=null;
// Adom hover tooltip for ANY element carrying data-tip — reuses the #tip box (dark,
// teal-bordered), clamped to the viewport so it never runs off an edge.
(function(){
  function box(){ let t=document.getElementById('tip'); if(!t){t=document.createElement('div');t.id='tip';document.body.appendChild(t);} return t; }
  let cur=null;
  function place(x,y){ const t=box(),r=t.getBoundingClientRect(); let lx=x+14,ty=y+16;
    if(lx+r.width>innerWidth-8) lx=x-r.width-14; if(ty+r.height>innerHeight-8) ty=y-r.height-10;
    t.style.left=Math.max(8,Math.min(lx,innerWidth-r.width-8))+'px'; t.style.top=Math.max(8,Math.min(ty,innerHeight-r.height-8))+'px'; }
  document.addEventListener('mouseover',e=>{ const el=e.target.closest&&e.target.closest('[data-tip]'); if(!el)return; cur=el; const t=box(); t.textContent=el.getAttribute('data-tip'); t.style.whiteSpace='normal'; t.style.maxWidth='240px'; t.style.display='block'; place(e.clientX,e.clientY); });
  document.addEventListener('mousemove',e=>{ if(cur){ if(cur.isConnected&&cur.matches(':hover')) place(e.clientX,e.clientY); else { cur=null; box().style.display='none'; } } });
  document.addEventListener('mouseout',e=>{ const el=e.target.closest&&e.target.closest('[data-tip]'); if(el&&el===cur){ cur=null; box().style.display='none'; } });
})();
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';
  // Both toolbar buttons grey out the SAME way (.off) with an Adom hover tooltip
  // (data-tip → the #tip box), never the native `disabled` (which swallows hover so
  // the tooltip can't show). Clicks are guarded in openAddLib / openAddProj.
  const be = boardSlug ? blockedEda(boardSlug) : null;   // 'Altium' | 'Fusion' | null
  const b=document.getElementById('btn-addlib');
  if(b){ b.textContent=n?`Add ${n} to Library`:'Add to Library';
    if(be) setOff(b, true, `Library delivery for ${be} components is coming soon`);
    else setOff(b, n===0, 'Select components below to add them to your library'); }
  const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s));
  const bp=document.getElementById('btn-addproj');
  if(bp){ if(be) setOff(bp, true, `Project delivery for ${be} boards is coming soon`); else setOff(bp, false, ''); }
}
// Mark a button unavailable: greyed (.off), still hoverable, with an Adom tooltip.
function setOff(el, off, tip){
  el.classList.toggle('off', off);
  if(off) el.setAttribute('data-tip', tip); else el.removeAttribute('data-tip');
}
// 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(()=>{});
    loadLibraries();
    render();
    if(!fresh) finishLoader();
    if(fresh) toast('Refreshed, '+all.length+' project'+(all.length===1?'':'s'),'success');
  }catch(e){
    if(!fresh) finishLoader();
    rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load, retry</div>';
    toast('Could not load projects from the wiki','error');
  }finally{ if(btn)btn.classList.remove('spin'); }
}
function render(){
  const ws=document.getElementById('workspace');
  const sr=document.getElementById('sortrow'); if(sr)sr.style.display='none';
  if(boardSlug){ ws.classList.remove('home'); renderDeps(); }
  else if(projectSlug){ ws.classList.remove('home'); renderProjectBoards(); }
  else renderGallery();   // top level = the full-screen gallery (adds .home)
}
// ── HOME gallery: the full-screen board wall. Kind-colored hero cards; hover swaps
// its hero gently zooms on hover. Personal is flat; Organization + Public group by
// org with avatar section headers. Default sort = most recent (last push). ──
const KIND_COLOR={board:'#79b8ff',molecule:'#f0a81c',circuit:'#f472b6',library:'#b9a4ff',project:'#2dd4bf'};
function galleryTabs(){
  // Tab counts match the visible set (same filters the wall applies, incl. library hiding).
  const boards=all.filter(isListable).filter(matchesFilter);
  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',null]];
  const bar=document.getElementById('home-tabs');
  bar.innerHTML=defs.map(([k,label,n])=>`<button class="segbtn${boardTab===k?' active':''}" data-tab="${k}">${label}${n!=null?`<span class="segn">${n}</span>`:''}</button>`).join('');
  bar.querySelectorAll('.segbtn').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; userView=null; orgView=null; renderGallery(); });
}
// Org section headers can collapse (a chevron hides the whole group) and, on the Public
// page, carry the org's aggregate engagement so the "top organizations" ranking is explicit.
let collapsedGroups=new Set();
function secHead(label,count,org,opts){
  opts=opts||{};
  const h=document.createElement('div');h.className='g-sec-head';
  const collKey=opts.collKey;
  const collapsed=collKey&&collapsedGroups.has(collKey);
  // A collapsible group gets a chevron (toggles the group). If opts.orgSlug is set, the
  // avatar+name are their own click target that drills into that org's boards — so
  // collapsing and drilling in are two distinct clicks on the same header.
  const chev=collKey?`<button class="gsh-chev${collapsed?' coll':''}" title="${collapsed?'expand':'collapse'} group"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>`:'';
  const av=org?`<img class="gsh-avatar" src="org-avatar/${encodeURIComponent(org)}" onerror="this.remove()" alt="">`:'';
  const eng=(opts.eng!=null)?`<span class="gsh-eng" title="total engagement across this org's public boards">▲ ${Math.round(opts.eng)}</span>`:'';
  const drill=opts.orgSlug;
  h.innerHTML=`${chev}<span class="gsh-main${drill?' link':''}"${drill?` title="view all of ${esc(drill)}'s boards"`:''}>${av}<span class="gsh-label">${esc(label)}</span><span class="gsh-count">${count}</span>${eng}</span>`;
  if(collKey){ h.querySelector('.gsh-chev').onclick=(e)=>{ e.stopPropagation(); collapsedGroups.has(collKey)?collapsedGroups.delete(collKey):collapsedGroups.add(collKey); renderGallery(); }; }
  if(drill){ h.querySelector('.gsh-main').onclick=()=>viewOrg(drill); }
  return h;
}
function galleryCard(c){
  const kind=boardKind(c), kc=KIND_COLOR[kind]||'#79b8ff';
  const card=document.createElement('div'); card.className='gcard'; card.dataset.slug=c.slug;
  card.style.setProperty('--kc',kc);
  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>`:'';
  const edaSpan=Array.isArray(c.eda)?edaTags(c.eda):'';
  const star=c.starred?'<span class="g-star" title="starred on the wiki">★</span>':'';
  const mono=esc(((c.title||c.slug).trim().charAt(0)||'?'));
  const t=c.updated?Date.parse(c.updated):NaN;
  const when=isNaN(t)?'':timeago(Math.floor(t/1000));
  card.innerHTML=`
    <div class="g-thumb">
      <div class="g-ph">${mono}</div>
      <img class="g-hero" loading="lazy" alt="">
      ${star}
    </div>
    <div class="g-body">
      <div class="g-title">${esc(c.title||c.slug)}</div>
      <div class="g-meta"><span class="tag kind ${kind}">${kind}</span>${edaSpan}${visTag}</div>
      <div class="g-sub"><span class="g-owner${c.owner?' link':''}" title="${c.owner?'view '+esc(c.owner)+"'s boards":''}">${esc(c.owner||c.slug)}</span><span class="g-right"><span class="g-eng" title="engagement score">▲ ${Math.round(c.score||0)}</span>${when?`<span class="g-when">${when}</span>`:''}</span></div>
    </div>`;
  // hero image (lazy). On 404/no-hero, hide it so the kind-colored placeholder shows.
  const hero=card.querySelector('.g-hero');
  hero.onerror=()=>{ hero.style.display='none'; };   // 404/no hero → kind-colored monogram shows through
  hero.src='board-hero/'+c.slug;
  card.onclick=()=>isProject(c)?openProject(c.slug):openBoard(c.slug);
  // clicking the author name pivots the wall to that person's boards (not the card open)
  if(c.owner){ const ow=card.querySelector('.g-owner'); ow.onclick=(e)=>{ e.stopPropagation(); viewUser(c.owner); }; }
  return card;
}
function galleryEmpty(tab){
  if(tab==='mine')return '<div class="g-empty">No personal projects yet.<br>Push a board tagged <b>board</b>/<b>molecule</b> (without <span style="font-family:monospace">--org</span>), then press ↻.</div>';
  if(tab==='org')return '<div class="g-empty">No projects in your organization(s) yet.</div>';
  return '<div class="g-empty">No public projects match.</div>';
}
// Clicking an author name (or an org header) filters the wall to that person's / org's
// boards, across every scope Project Manager can see. null = normal tab view.
let userView=null, orgView=null;
function viewUser(name){ if(!name)return; userView=name; orgView=null; renderGallery(); }
function clearUserView(){ userView=null; renderGallery(); }
function viewOrg(org){ if(!org)return; orgView=org; userView=null; renderGallery(); }
function clearOrgView(){ orgView=null; renderGallery(); }
const byScoreCmp=(a,b)=>((b.score||0)-(a.score||0))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase());
function renderGallery(){
  document.getElementById('workspace').classList.add('home');
  galleryTabs();
  fillChips(document.getElementById('home-chips'));
  const grid=document.getElementById('home-grid'); grid.innerHTML='';
  const searchEl=document.getElementById('home-q');
  searchEl.placeholder='search projects…';
  const q=(searchEl.value||'').trim().toLowerCase();
  const search=c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)||(c.owner||'').toLowerCase().includes(q);
  // Libraries are hidden by default; the Library filter chip reveals them (and pulls in
  // the board-embedded libraries too).
  const showLibs=filterTags.has('library');
  const pool=showLibs ? all.filter(c=>isListable(c)||isLibrary(c))
                      : all.filter(c=>isListable(c)&&boardKind(c)!=='library');
  // ── org drill-down: every board in one organization, newest first ──
  if(orgView){
    const list=sortBoards(pool.filter(c=>c.org===orgView).filter(search).filter(matchesFilter));
    grid.appendChild(orgBanner(orgView, list.length));
    if(!list.length){ const e=document.createElement('div'); e.className='g-empty'; e.textContent=`No boards in ${orgView}.`; grid.appendChild(e); return; }
    list.forEach(c=>grid.appendChild(galleryCard(c)));
    return;
  }
  // ── author drill-down: every board by one person, newest first ──
  if(userView){
    const list=sortBoards(pool.filter(c=>c.owner===userView).filter(search).filter(matchesFilter));
    grid.appendChild(userBanner(userView, list.length));
    if(!list.length){ const e=document.createElement('div'); e.className='g-empty'; e.textContent=`No boards by ${userView}.`; grid.appendChild(e); return; }
    list.forEach(c=>grid.appendChild(galleryCard(c)));
    return;
  }
  if(boardTab==='mine'){
    const list=sortBoards(pool.filter(c=>c.owner&&c.owner===me).filter(search).filter(matchesFilter));
    if(!list.length){ grid.innerHTML=galleryEmpty('mine'); return; }
    list.forEach(c=>grid.appendChild(galleryCard(c)));
  } else if(boardTab==='org'){
    const list=pool.filter(c=>c.org&&orgs.includes(c.org)).filter(search).filter(matchesFilter);
    if(!list.length){ grid.innerHTML=galleryEmpty('org'); return; }
    orgs.forEach(o=>{ const inOrg=sortBoards(list.filter(c=>c.org===o)); if(!inOrg.length)return;
      const key='org:'+o;
      grid.appendChild(secHead(o,inOrg.length,o,{collKey:key,orgSlug:o}));
      if(!collapsedGroups.has(key)) inOrg.forEach(c=>grid.appendChild(galleryCard(c))); });
  } else {
    // PUBLIC. Searching/filtering → flat results. Otherwise a leaderboard: the top 9
    // boards overall, then a deliberate TOP ORGANIZATIONS ranking — the strongest orgs
    // by aggregate engagement, each a collapsible group showing its top 3 boards.
    const pub=pool.filter(c=>{const v=(c.visibility||'').toLowerCase();return v==='public'||v==='open';}).filter(matchesFilter);
    if(q){
      const list=sortBoards(pub.filter(search));
      if(!list.length){ grid.innerHTML=galleryEmpty('public'); return; }
      grid.appendChild(secHead('Results',list.length,null)); list.forEach(c=>grid.appendChild(galleryCard(c)));
      return;
    }
    if(!pub.length){ grid.innerHTML=galleryEmpty('public'); return; }
    const top9=pub.slice().sort(byScoreCmp).slice(0,9);
    grid.appendChild(secHead('Trending',top9.length,null));
    top9.forEach(c=>grid.appendChild(galleryCard(c)));
    const groups={}; pub.forEach(c=>{ if(c.org){ (groups[c.org]=groups[c.org]||[]).push(c); } });
    const engOf=o=>groups[o].reduce((m,c)=>m+(c.score||0),0);
    const orgRank=Object.keys(groups).sort((a,b)=>engOf(b)-engOf(a)||a.localeCompare(b)).slice(0,6);
    if(orgRank.length){
      grid.appendChild(secHead('Top organizations',orgRank.length,null));
      orgRank.forEach(o=>{ const arr=groups[o].slice().sort(byScoreCmp); const key='puborg:'+o;
        grid.appendChild(secHead(o,arr.length,o,{collKey:key,eng:engOf(o),orgSlug:o}));
        if(!collapsedGroups.has(key)) arr.slice(0,3).forEach(c=>grid.appendChild(galleryCard(c))); });
    }
  }
}
// Full-width banner for the author drill-down, with a ✕ to return to the tabs.
function userBanner(name,count){
  const h=document.createElement('div'); h.className='g-sec-head g-userbanner';
  h.innerHTML=`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg><span>Boards by ${esc(name)}</span><span class="gsh-count">${count}</span><button class="g-clearuser" title="back to all">✕</button>`;
  h.querySelector('.g-clearuser').onclick=(e)=>{ e.stopPropagation(); clearUserView(); };
  return h;
}
// Full-width banner for the org drill-down (org avatar + name), with a ✕ to return.
function orgBanner(org,count){
  const h=document.createElement('div'); h.className='g-sec-head g-userbanner';
  h.innerHTML=`<img class="gsh-avatar" src="org-avatar/${encodeURIComponent(org)}" onerror="this.remove()" alt="">`+
    `<span>Boards in ${esc(org)}</span><span class="gsh-count">${count}</span><button class="g-clearuser" title="back to all">✕</button>`;
  h.querySelector('.g-clearuser').onclick=(e)=>{ e.stopPropagation(); clearOrgView(); };
  return h;
}
// 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.
// Chips are an INCLUDE set shown all-on EXCEPT Library, so it reads as "everything but
// libraries is in the list". Clicking Library adds libraries; clicking another off hides
// that category.
let filterTags=new Set(['project','molecule','board','circuit','kicad','altium','fusion']);
const FILTER_CHIPS=[['project','Project'],['molecule','Molecule'],['board','Board'],['circuit','Circuit'],['library','Library'],['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);
    document.getElementById('workspace').classList.contains('home')?renderGallery():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')); }
// Add Project delivers a KiCad project; Altium/Fusion boards can't be delivered yet.
// Block those (unless the board also carries KiCad, which always works).
// Which EDA blocks delivery for this board — 'Altium' | 'Fusion' | null. KiCad always
// works; a non-KiCad board's EDA (Altium/Fusion) isn't deliverable yet. Used to gray
// out BOTH Add Project and Add to Library with an EDA-specific "coming soon" tooltip.
function blockedEda(slug){ const e=boardEdaOf(bySlug[slug]||{slug})||[]; if(e.includes('kicad'))return null; if(e.includes('altium'))return 'Altium'; if(e.includes('fusion'))return 'Fusion'; return null; }
function addProjBlocked(slug){ return !!blockedEda(slug); }
// 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){
  const kind=boardKind(c);
  const kindSel=['project','molecule','board','circuit','library'].filter(t=>filterTags.has(t));
  const edaSel=['kicad','altium','fusion'].filter(t=>filterTags.has(t));
  // Kind facet is an include set. If any kind chips are on, the board's kind must be one
  // of them; if none are on, fall back to "everything except library".
  if(kindSel.length){ if(!kindSel.includes(kind)) return false; }
  else if(kind==='library') return false;
  // EDA facet only constrains boards that actually declare an EDA — projects and
  // eda-less boards always pass, so the default all-on EDA chips never hide them.
  if(edaSel.length){ const eda=boardEdaOf(c); if(eda&&eda.length&&!eda.some(t=>edaSel.includes(t))) return false; }
  return true;
}
function renderBoards(){
  document.getElementById('toolbar').style.display='none';selected.clear();
  document.getElementById('crumb').innerHTML='';   // no redundant "Projects" heading above the tabs
  const q=qval(),el=rowsEl();el.innerHTML='';
  renderTabs(); renderFilterBar();
  const boards=all.filter(isListable);
  // When the Library filter is on, pull the board-embedded libraries back into the
  // rendered list so they show up as normal rows (they're hidden under their board
  // otherwise). Tab counts still come from the base `boards` set.
  const pool=filterTags.has('library') ? all.filter(c=>isListable(c)||isLibrary(c)) : boards;
  const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';
  // Sort dropdown: only on the personal/org lists (Public is its own leaderboard).
  const srow=document.getElementById('sortrow'); if(srow)srow.style.display=boardTab==='public'?'none':'';
  if(boardTab==='public'){renderPublic(pool.filter(matchesFilter),el);return;}
  const mineList=pool.filter(c=>c.owner&&c.owner===me), orgList=pool.filter(c=>c.org&&orgs.includes(c.org));
  const list=(boardTab==='mine'?mineList:orgList).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':t.includes('circuit')?'circuit':t.includes('board')?'board':t.includes('library')?'library':'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,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
  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)}"><svg class="fb-ico" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4l2 2.5h7A1.5 1.5 0 0 1 19 9v8.5A1.5 1.5 0 0 1 17.5 19h-13A1.5 1.5 0 0 1 3 17.5z"/></svg> ${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 this board/molecule/project has its own (non-standalone) library, add a
  // "Library" expander that reveals it inline instead of listing it separately.
  const libs=librariesFor(c.slug);
  if(libs.length){
    const btn=document.createElement('button');btn.type='button';btn.className='libtoggle';
    btn.innerHTML=`<span class="cv">▾</span>${libs.length>1?`${libs.length} libraries`:'Libraries'}`;
    btn.title=`show the library for this ${kind}`;
    btn.onclick=(e)=>{ e.stopPropagation(); toggleLibChild(d,btn,libs); };
    d.appendChild(btn);   // its own line, below the tag row
  }
  if(!edaKnown)loadEda(c.slug);
  return d;
}
function toggleLibChild(row,btn,libs){
  const nx=row.nextElementSibling;
  if(nx&&nx.classList.contains('libchild')){ nx.remove(); btn.classList.remove('open'); return; }
  const wrap=document.createElement('div');wrap.className='libchild';
  libs.forEach(l=>{
    const lr=document.createElement('div');lr.className='row libchild-row';lr.dataset.slug=l.slug;
    const vis=(l.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>`:'';
    const edaSpan=Array.isArray(l.eda)?edaTags(l.eda):'';
    const other=edaSpan+visTag;
    // embedded library row mirrors a normal board row: purple "library" kind tag FIRST,
    // then its eda/visibility tags, all on one line.
    lr.innerHTML=`<div class="mpn">${l.title||l.slug}</div><div class="sub">${l.slug}${l.owner?`<span class="auth">${l.owner}</span>`:''}</div><div class="row-tags"><span class="tag kind library">library</span>${other}</div>`;
    lr.onclick=(ev)=>{ ev.stopPropagation(); openBoard(l.slug); };
    wrap.appendChild(lr);
  });
  row.after(wrap); btn.classList.add('open');
}
// Sort mode chosen in the list's "Sort" dropdown. Starred boards always pin to the
// top first; the mode orders everything else. alpha=A→Z, tag=molecule/library/project/
// board, eda=kicad/altium/fusion, score=engagement (desc). Ties fall back to alpha.
let sortMode='recent';
function sortBoards(list){
  const alpha=(a,b)=>(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase());
  let cmp=alpha;
  if(sortMode==='recent'){ const ts=c=>{const t=Date.parse(c.updated||'');return isNaN(t)?0:t;}; cmp=(a,b)=>(ts(b)-ts(a))||alpha(a,b); }
  else if(sortMode==='tag'){ const rk={molecule:0,library:1,project:2,board:3}; cmp=(a,b)=>((rk[boardKind(a)]??9)-(rk[boardKind(b)]??9))||alpha(a,b); }
  else if(sortMode==='eda'){ const er={kicad:0,altium:1,fusion:2}; const rank=c=>{const e=boardEdaOf(c)||[];let m=9;e.forEach(x=>{if(er[x]!=null)m=Math.min(m,er[x]);});return m;}; cmp=(a,b)=>(rank(a)-rank(b))||alpha(a,b); }
  else if(sortMode==='score'){ cmp=(a,b)=>((b.score||0)-(a.score||0))||alpha(a,b); }
  return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||cmp(a,b));
}
// 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)"><div class="orgtext"><div class="orgtop"><span class="orgname">${org}</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span></div><div class="orgmeta">${a.count} project${a.count>1?'s':''}</div></div>`;
      d.onclick=()=>{pubOrg=org;renderPubResults();};
      pr.appendChild(d);
    });
  }
}
async function openBoard(slug){
  document.getElementById('workspace').classList.remove('home');   // leave the gallery, interior takes over
  boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;
  const title=(bySlug[slug]||{}).title||slug;
  setSubject(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); curBom=(r.bom||[]); fillBoardInfo(slug,r.log); }
  catch(e){ if(boardSlug!==slug)return; curDeps=[]; curBom=[]; }
  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 _wowner=c.org||c.owner||'';
  const _wikiUrl=_wowner?`https://wiki.adom.inc/${_wowner}/${encodeURIComponent(slug)}`:'';
  const _wikiBtn=_wikiUrl?`<a class="wikibtn" href="${_wikiUrl}" target="_blank" rel="noopener" title="open this page on the Adom wiki">Open on wiki ↗</a>`:'';
  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>${_wikiBtn}</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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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>molecule-publish</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){
      if(boardSlug!==slug)return;
      d3.style.position='relative'; d3.style.background='#0d0f14';
      // The canonical Babylon adom-3d-viewer, hosted BY Project Manager at same-origin /viewer3d:
      // it loads the wiki's own viewer bundle through Project Manager's /v3d/ proxy and the model
      // through Project Manager's authenticated board-glb route. Because both are same-origin, no
      // cross-site session cookie is needed — so PUBLIC and PRIVATE boards both render the
      // real Babylon viewer (a direct cross-origin wiki iframe 404s on a private page when
      // the SameSite=Lax cookie is stripped).
      const vq=boardVersion?('&v='+encodeURIComponent(boardVersion)):'';
      d3.innerHTML=`<iframe class="bo-embed" style="background:#0d0f14;border:0" src="viewer3d?slug=${encodeURIComponent(slug)}${vq}" allow="fullscreen" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`;
    } else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;
  }
  const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');
  if(sel)sel.style.display='none'; // the interactive schematic viewer shows the whole sheet
  // Interactive views: adom-schematic (schematic) + adom-2dboard (PCB),
  // served as self-contained embeds by the backend /board-view route and iframed here.
  if(sch){
    if(sheets.length){ sch.innerHTML=embedFrame(`board-view/${slug}/sch${q}`,'#ffffff'); }
    else { sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }
  }
  if(boardSlug!==slug)return;
  if(pcb){ if(m.has_pcb){ pcb.innerHTML=embedFrame(`board-view/${slug}/pcb${q}`,'#0d1117'); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }
}
// Build an interactive-viewer iframe with a loading spinner that clears on load.
function embedFrame(src,bg){ return `<iframe class="bo-embed" style="background:${bg}" src="${src}" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`; }
// The wiki's own 3D viewer (adom-3d-viewer-babylon9), iframed so the model looks
// exactly as it does on the wiki page. The viewer page is self-contained (bundle +
// Draco + CSP) and has no frame-ancestors restriction, so it embeds cross-origin.
function wikiViewer3d(slug){
  // Owner-QUALIFIED viewer route. The bare /viewer/3d/component/<slug> form is legacy and
  // resolves owner:null, which 404s for private org pages (adom/adom-3d-viewer #1, Colby):
  // the viewer then can't derive the model and the pane errors out. Always pass the page's
  // own owner (org for org pages, else the author), and NEVER an ownerless lookup. Pass the
  // slug verbatim — never append "-board" (many board slugs already end in it).
  const c=bySlug[slug]||{};
  const owner=c.org||c.owner;
  if(!owner){ return '<div class="bo-na">3D not available</div>'; }  // no owner ⇒ don't send a lookup that can't resolve
  const src=`https://wiki.adom.inc/viewer/3d/${encodeURIComponent(owner)}/component/${encodeURIComponent(slug)}`;
  return `<iframe class="bo-embed" style="background:#0d0f14;border:0" src="${src}" allow="fullscreen" onload="this.nextElementSibling&&this.nextElementSibling.remove()"></iframe><div class="vload ov"><img src="loader.gif" alt=""></div>`;
}
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'];
const CAT_TAGS={capacitors:'Capacitors',resistors:'Resistors',inductors:'Inductors','power-ics':'Power ICs',diodes:'Diodes',leds:'LEDs',connectors:'Connectors',mosfets:'MOSFETs',transistors:'Transistors',microcontrollers:'Microcontrollers',crystals:'Crystals & Oscillators',ics:'ICs'};
function typeOf(c){
  // an explicit category tag wins over keyword-guessing (a resistor whose description
  // mentions the circuit it's used in must still group under Resistors).
  for(const t of (c.tags||[]).map(x=>(''+x).toLowerCase())) if(CAT_TAGS[t]) return CAT_TAGS[t];
  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(){
  document.getElementById('tabbar').innerHTML='';   // global scope tabs live on Home now
  document.getElementById('tagfilter').style.display='none';
  const _sr=document.getElementById('sortrow'); if(_sr)_sr.style.display='none';
  document.getElementById('q').placeholder='filter components…';
  document.getElementById('toolbar').style.display='flex';
  const q=qval(),el=rowsEl();el.innerHTML='';
  // Circuits carry their BOM in the block model (adom-block.json), not as resolved
  // component pages: render that as the dependency list when there are no page deps.
  if(!curDeps.length && curBom.length){ renderBom(el,q); updateToolbar(); return; }
  if(!curDeps.length){el.innerHTML='<div class="deps-none">This project lists <b>no component dependencies</b> — its BOM can’t resolve. Add the component pages it uses as dependencies of the board itself.</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(){ setSubject('','');
  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(){ setSubject('','');
  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){
  document.getElementById('workspace').classList.remove('home');   // leave the gallery, interior takes over
  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>';
}
// Render a circuit's BOM (from its block model) as the dependency list. Each row is
// a part: "qty× value", with LCSC + footprint; it links to a wiki component page when
// one was resolved, otherwise it's an informative (non-clickable) BOM line.
function renderBom(el,q){
  const rows=curBom.filter(p=>{const s=[(p.value||''),(p.mpn||''),(p.lcsc||''),(p.footprint||'')].join(' ').toLowerCase();return !q||s.includes(q);});
  const h=document.createElement('div');h.className='cathead';h.textContent=`Bill of materials · ${rows.length} part${rows.length===1?'':'s'}`;el.appendChild(h);
  rows.forEach(p=>{
    const d=document.createElement('div');d.className='row bomrow'+(p.page?'':' nolink');
    const title=(p.value||p.mpn||'part');
    const qty=p.qty>1?`<span class="bom-qty">${p.qty}×</span>`:'';
    const refs=(p.refs&&p.refs.length)?` <span class="bom-refs">${p.refs.join(', ')}</span>`:'';
    const meta=[p.lcsc?`LCSC ${p.lcsc}`:'',p.footprint||''].filter(Boolean).join(' · ');
    d.innerHTML=`<div class="mpn">${qty}${title}${refs}</div><div class="sub">${meta}${p.page?'':' · not on wiki'}</div>`;
    if(p.page){ d.onclick=()=>openBoard(p.page.split('/').pop()); } else { d.style.cursor='default'; }
    el.appendChild(d);
  });
}
function renderProjectBoards(){
  document.getElementById('tabbar').innerHTML='';   // global scope tabs live on Home now
  document.getElementById('tagfilter').style.display='none';
  const _sr=document.getElementById('sortrow'); if(_sr)_sr.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;}
  // component 3D via the SAME wiki viewer as boards (adom-3d-viewer-babylon9),
  // so a part looks identical here and on its wiki page.
  const glb=p.glb?`<div class="body embed">${wikiViewer3d(slug)}</div>`:'<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.
// Never renders a DEAD control: with no libraries detected it says so (and the modal
// offers a retry), and with no guess it shows an explicit "pick a library" placeholder
// rather than pre-selecting whichever library happened to be first.
// Is this library one of KiCad's BUNDLED ones rather than the user's own? Reported by
// the server (it knows the ${KICAD*_DIR} / install-path entries it filtered out).
function isStockLib(name,k){
  if(!name)return false;
  const list=(k==='symLib'?userLibs.stockSymLibs:userLibs.stockFpLibs)||[];
  return list.includes(name);
}
function libSelect(libs,sel,i,k){
  if(!libs||!libs.length)
    return `<select class="libsel nolibs" data-i="${i}" data-k="${k}" disabled><option>${libsGaveUp?'no libraries detected':'detecting your libraries…'}</option></select>`;
  // `sel` may name a library that isn't one of the user's PERSONAL ones — a part
  // already in their library commonly points at a stock KiCad footprint library
  // (Resistor_SMD). Keep it as the selected option rather than dropping it, and
  // HIGHLIGHT it so "this is KiCad's, not yours" is obvious at a glance.
  const stock=isStockLib(sel,k);
  const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}${stock?' — KiCad default':''}</option>`:'';
  const ph=sel?'':`<option value="" selected>— pick a library —</option>`;
  const mk=`<option value="__new__">+ new library…</option>`;
  const cls='libsel'+(sel?'':' unset')+(stock?' stock':'');
  const tip=stock?` data-tip="${sel} is one of KiCad's built-in libraries, not one of yours"`:'';
  return `<select class="${cls}" data-i="${i}" data-k="${k}"${tip}>${ph}${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}${mk}</select>`;
}
// Create a brand-new symbol/footprint library on the user's machine and select it.
// `applyAll` fills every row still missing one, since a new manufacturer library is
// almost always wanted for the whole group that triggered creating it.
async function newLibraryFlow(kind,rowIndex){
  const what=kind==='symLib'?'symbol':'footprint';
  const name=prompt(`Name for the new ${what} library\n\nA ${what} library will be created on your machine and registered with KiCad.`, '');
  if(name===null)return false;
  if(!name.trim()){toast('A library name is required','info');return false;}
  toast(`Creating ${name.trim()} …`,'info');
  try{
    const r=await fetch(`api/create-library?kind=${kind==='symLib'?'sym':'fp'}&name=`+encodeURIComponent(name.trim()));
    const d=await r.json();
    if(!d||!d.ok){toast((d&&d.error)||'Could not create the library','error');return false;}
    if(d.symLibs)userLibs.symLibs=d.symLibs;
    if(d.fpLibs)userLibs.fpLibs=d.fpLibs;
    if(curPlan[rowIndex])curPlan[rowIndex][kind]=d.name;
    curPlan.forEach(p=>{if(!p[kind])p[kind]=d.name;});
    toast(`Created ${d.name} at ${d.path}`,'success');
    if(typeof curRedraw==='function')curRedraw();
    return true;
  }catch(e){toast('Could not create the library','error');return false;}
}
// Nothing is delivered until every row has a real target library, so a missing guess
// can never silently become "whatever was first in the list".
function planReady(){return curPlan.length>0&&curPlan.every(p=>p.symLib&&p.fpLib&&(p.symName||'').trim());}
function updateGoState(){
  const go=document.getElementById('modal-go');if(!go)return;
  const ok=planReady();go.disabled=!ok;
  setTip(go, ok?'':(!libCount()?'Your KiCad libraries haven\'t been detected yet':(curPlan.some(p=>!(p.symName||'').trim())?'Every component needs a symbol name':'Pick a target library for every component first')));
}
function setTip(el,tip){if(tip)el.setAttribute('data-tip',tip);else el.removeAttribute('data-tip');}
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)}${isStockLib(p.symLib,'symLib')?' stock':''}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}${isStockLib(p.fpLib,'fpLib')?' stock':''}">${p.fpLib}.pretty</span></td>`;
  // what this part will be CALLED inside the user's library — editable, because the
  // name a component page ships with isn't always what they want on their shelf
  const nameCell=(p,i)=>editable
    ? `<td><input class="symname${p.symName&&p.symNameDefault&&p.symName!==p.symNameDefault?' edited':''}" data-i="${i}" value="${(p.symName||'').replace(/"/g,'&quot;')}" placeholder="${symNamesLoading?'loading…':'symbol name'}" spellcheck="false"></td>`
    : `<td><span class="lib">${p.symName||''}</span></td>`;
  // the green "already in your library" tag names BOTH libraries it was found in, so
  // the footprint side is visibly resolved rather than looking like an unfilled guess
  const foundTag=inl=>{
    const tag=(n,k)=>n?`<span class="${isStockLib(n,k)?'stocktag':''}">${n}</span>`:'';
    const parts=[tag(inl.lib,'symLib'),tag(inl.fpLib,'fpLib')].filter(Boolean);
    return `<span class="inlibtag">✓ ${parts.length?parts.join(' · '):'in your library'}</span>`;};
  let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?' '+foundTag(inl):''}</td>${nameCell(p,i)}<td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');
  // Parts whose manufacturer has no library of its own (a Samsung cap when the
  // footprint libraries are per-manufacturer) can't be guessed — rather than make the
  // user set them one at a time, offer to fill every remaining row in one go.
  const bulk=editable?bulkSetRow(plan):'';
  // 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>-</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');
  }
  return bulk+`<table><thead><tr><th>Component</th><th>Symbol name in your library</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`;
}
// "Set all remaining" control, shown only while some row still has no target library.
function bulkSetRow(plan){
  const needSym=plan.filter(p=>!p.symLib).length, needFp=plan.filter(p=>!p.fpLib).length;
  if(!needSym&&!needFp)return '';
  const opts=libs=>`<option value="">— set all remaining to —</option>`+libs.map(l=>`<option value="${l.name}">${l.name}</option>`).join('')+`<option value="__new__">+ new library…</option>`;
  const sym=needSym&&userLibs.symLibs.length?`<span class="bulkitem">${needSym} symbol${needSym>1?'s':''} <select class="libsel" id="bulk-symLib">${opts(userLibs.symLibs)}</select></span>`:'';
  const fp=needFp&&userLibs.fpLibs.length?`<span class="bulkitem">${needFp} footprint${needFp>1?'s':''} <select class="libsel" id="bulk-fpLib">${opts(userLibs.fpLibs)}</select></span>`:'';
  if(!sym&&!fp)return '';
  return `<div class="bulkset">Still unassigned: ${sym}${fp}</div>`;
}
function wireBulkSets(){
  ['symLib','fpLib'].forEach(k=>{
    const s=document.getElementById('bulk-'+k); if(!s)return;
    s.onchange=async()=>{ if(!s.value)return;
      if(s.value==='__new__'){ s.value=''; await newLibraryFlow(k,0); return; }
      curPlan.forEach(p=>{if(!p[k])p[k]=s.value;});
      if(typeof curRedraw==='function')curRedraw(); };
  });
}
// the symbol name each part ships with (slug → name), from /api/symbol-names, so the
// editable name column starts at the REAL default rather than a guess
let symNames={}, symNamesLoading=false, symNameEdits={};
function loadSymbolNames(slugs,cb){
  const want=slugs.filter(s=>!(s in symNames));
  if(!want.length){if(cb)cb();return;}
  symNamesLoading=true; if(cb)cb();
  fetch('api/symbol-names?slugs='+encodeURIComponent(want.join(',')))
    .then(r=>r.json()).then(m=>{Object.assign(symNames,m||{});})
    .catch(()=>{})
    .finally(()=>{symNamesLoading=false;
      curPlan.forEach(p=>{if(!p.symName&&symNames[p.slug]){p.symName=symNames[p.slug];p.symNameDefault=symNames[p.slug];}});
      if(cb)cb();});
}
// wire the editable symbol-name inputs to the live plan
function wireSymNames(){
  document.querySelectorAll('#modal-preview .symname').forEach(inp=>{
    inp.oninput=()=>{const p=curPlan[+inp.dataset.i]; if(!p)return;
      p.symName=inp.value; symNameEdits[p.slug]=inp.value;
      inp.classList.toggle('edited',!!p.symNameDefault&&inp.value!==p.symNameDefault);
      updateGoState();};
  });
}
// 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[data-i]').forEach(s=>{
    const prev=s.value;
    s.onchange=async()=>{
      if(s.value==='__new__'){ s.value=prev; if(!await newLibraryFlow(s.dataset.k,+s.dataset.i)){ if(typeof curRedraw==='function')curRedraw(); } return; }
      if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;
      s.classList.toggle('unset',!s.value);
      updateGoState();
    };
  });
  updateGoState();
}
function buildPlan(slugs,projectLib,projName){
  return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);
    const dn=symNames[s]||'';
    const cur=(s in symNameEdits)?symNameEdits[s]:dn;
    return {slug:s,title:c.title||s,type:t,symName:cur,symNameDefault:dn,
            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;curRedraw=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 beL=boardSlug&&blockedEda(boardSlug); if(beL){ toast(`Library delivery for ${beL} components is coming soon`,'info'); return; }
  const slugs=[...selected]; if(!slugs.length){ toast('Select components below to add them to your library','info'); return; }
  symNameEdits={};
  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={};
  // re-guess from the CURRENT userLibs, so a modal opened before the libraries
  // arrived fills itself in as soon as they do (see redrawLibSelects).
  const drawP=()=>{
    curPlan.forEach(p=>{const c=bySlug[p.slug];if(!c)return;
      if(!p.symLib)p.symLib=symLibFor(c); if(!p.fpLib)p.fpLib=fpLibFor(c);});
    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=libsBanner()+previewTable(curPlan,true);
    wireLibSels();wireBulkSets();wireSymNames();wireLibRetry();
  };
  curRedraw=drawP;
  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;curRedraw=null;}else{curRedraw=drawP;drawP();}});
  drawP(); loadSymbolNames(curPlan.map(p=>p.slug),drawP); checkInLib(curPlan.map(p=>p.slug),drawP);
  go.textContent='Yes, add it'; updateGoState();
  modalGo=()=>doApply(curPlan,'kicad',false);
  showModal();
}
// Shown in the modal when the user's libraries aren't available, with a retry that
// re-probes the bridge — the old build just rendered empty dropdowns and said nothing.
function libsBanner(){
  if(libCount())return '';
  return `<div class="note" style="border-color:#f0a81c">${libsGaveUp
    ? 'Your KiCad libraries could not be read from your machine. Check that Adom Desktop is connected and has shell permissions, then '
    : 'Detecting your KiCad libraries on your machine… '}<a href="#" id="libretry" style="color:#4ec9d9">retry now</a>.</div>`;
}
function wireLibRetry(){const a=document.getElementById('libretry');if(a)a.onclick=e=>{e.preventDefault();libsGaveUp=false;loadLibraries();redrawLibSelects();};}
let fbPrefetch=null;
function openAddProj(){
  const beP=boardSlug&&blockedEda(boardSlug); if(beP){ toast(`Project delivery for ${beP} boards is coming soon`,'info'); return; }
  // 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'; symNameEdits={};
  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=(editable?libsBanner():'')+`<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();wireBulkSets();wireSymNames();wireLibRetry();} else {const go=document.getElementById('modal-go');if(go)go.disabled=false;}
    modalGo=()=>doApply(curPlan,'kicad',mode==='project',boardSlug,destPath());
  };
  curRedraw=draw;
  // 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').textContent='Yes, add the project';
  inLib={}; projDiff={}; draw(); loadSymbolNames(slugs,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,symName:(p.symName||'').trim()}))})}).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);
      if(r.errors&&r.errors.length) toast(r.errors.length+' issue'+(r.errors.length===1?'':'s')+' while adding, see the dialog','error');
      else toast('Added '+(r.count||0)+' symbol'+(r.count===1?'':'s')+' to your libraries'+(pf.length?', delivered '+pf.length+' project file'+(pf.length===1?'':'s'):''),'success');
    }
  },350);
}
document.getElementById('btn-addlib').onclick=openAddLib;
document.getElementById('btn-addproj').onclick=openAddProj;

document.getElementById('q').oninput=render;
document.getElementById('refresh').onclick=()=>loadList(true);
// Version + update check: sets the app-info version and, when the wiki has a newer
// release, reveals the amber "Update available" badge (with the new version in it).
fetch('api/update').then(r=>r.json()).then(d=>{
  const el=document.getElementById('ai-ver');
  if(el&&d&&d.current)el.textContent='v'+d.current+(d.update&&d.latest?` · v${d.latest} available`:'');
  if(d&&d.update&&d.latest){
    const b=document.getElementById('update-badge'), t=document.getElementById('update-badge-txt');
    if(b){ b.classList.add('show'); b.title=`You're on v${d.current}. v${d.latest} is on the wiki — click to open the page.`;
      if(t)t.textContent=`Update to v${d.latest}`;
      if(d.url)b.href=d.url; }
  }
}).catch(()=>{});
loadList();
</script>
</body>
</html>