← Commit history
.gitignore+5−4
@@ -1,9 +1,10 @@ # Rust build output /target -# Compiled binary is built by build.sh at publish time, never committed-# (keeps the repo small and prevents stale binaries / accidental stub publishes).-/bin/adom-footprint- # Package tarballs *.tgz++# NOTE: bin/adom-footprint is intentionally NOT ignored. adompkg applies+# .gitignore on top of package.json "files", so ignoring it would drop the+# binary from the published tarball (breaking `adompkg install`). build.sh+# stages a fresh binary there before each publish; leave it untracked locally.
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-footprint"-version = "1.0.15"+version = "1.0.16" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.15"+version = "1.0.16" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer" 
README.md+12−2
@@ -3,9 +3,19 @@ Standalone Rust CLI + live app for **KiCad PCB footprints**. Generates `.kicad_mod` files natively in Rust, renders them as dark-themed SVGs through the shared **service-kicad** container, and serves an AI-drivable live viewer.-No Node, no gallia, no local KiCad install.+No Node, no local KiCad install. -![A footprint in the Adom footprint viewer](docs/screenshot-viewer.png)+![The adom-footprint live app — a full-bleed footprint canvas with the left chip-fetcher library rail and the Layers panel](docs/screenshot-viewer.png)++*The live app: browse the whole chip-fetcher library on the left (filter / ↑↓ / click, or deep-link with `?mpn=`); toggle layers on the right.*++![The RefDes/Value text-placement editor with Auto-layout, manual drag, undo/redo and per-tool naming](docs/screenshot-editor.png)++*Place the RefDes/Value text: one-click Auto-layout clears the pads, or drag/nudge it yourself — with undo and per-EDA naming (KiCad Reference/Value · Altium Designator/Comment · …). Saved into the `.kicad_mod` so every tool shows it.*++![The paste-dots layer — 300µm solder-paste dispensing positions on every pad](docs/screenshot-paste-dots.png)++*The paste-dots layer: 300µm solder-paste dispensing positions, computed for every footprint (off by default).*  ## What it does 
VERSION+1−1
@@ -1 +1 @@-1.0.15\ No newline at end of file+1.0.16\ No newline at end of file
bin/adom-footprint
⋯ 1 unchanged line ⋯
docs/screenshot-editor.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-paste-dots.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-viewer.png
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.15",+  "version": "1.0.16",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {
page.json+6−4
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.15",+  "version": "1.0.16",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {@@ -19,8 +19,10 @@     "pads"   ],   "hero": {-    "type": "image",-    "path": "adom-footprint-hero-v4.png"+    "type": "billboard",+    "headline": "KiCad footprints, instantly",+    "subhead": "Toggle copper, fab and paste; render and deliver footprints to KiCad and Fusion 360.",+    "screenshot": "screenshots/shot.png"   },   "scripts": {     "install": "./install.sh",@@ -36,4 +38,4 @@   "org": "adom",   "updated_at": "2026-06-25T01:59:28.504Z",   "source_visibility": "public"-}+}\ No newline at end of file
screenshots/shot.png
⋯ 1 unchanged line ⋯
src/app.html+365−27
@@ -70,24 +70,47 @@     .layer-row .chk { width:15px; height:15px; flex:0 0 15px; border-radius:4px; border:1.5px solid #3a4757; display:grid; place-items:center; color:#0b0f17; font-size:11px; }     .layer-row.on .chk { background:var(--accent2); border-color:var(--accent2); }     .layer-row.off .lname, .layer-row.off .sw { opacity:.4; }-    .layer-row.paste-dots { background:linear-gradient(100deg,rgba(55,200,166,.10),transparent); border:1px solid rgba(55,200,166,.25); margin-top:4px; }-    .layer-row.paste-dots .lname { color:#7ef0d2; font-weight:600; }     .hud-foot { padding:7px 11px; border-top:1px solid #232c39; font-size:10px; color:var(--text3); line-height:1.4; }     .hud-foot b { color:#7ef0d2; font-weight:600; }     .layers-reopen { background:var(--surface2,#1c2230); border:1px solid var(--border2); color:var(--text); font-size:11px; border-radius:7px; padding:4px 9px; cursor:pointer; display:inline-flex; align-items:center; gap:5px; }     .layers-reopen:hover { border-color:var(--accent2); color:var(--accent2); } -    main { flex:1; display:flex; align-items:center; justify-content:center; padding:28px; min-height:0; }-    .card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:20px 24px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(94vw,760px); width:100%; }-    .card .name { font-size:20px; font-weight:700; }-    .card .meta { color:var(--muted); font-size:13px; margin-top:2px; }-    .card .desc { color:#aeb6c2; font-size:13px; margin-top:6px; line-height:1.4; }-    .svgbox { margin-top:16px; border:1px solid var(--border); border-radius:10px; background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%); overflow:hidden; position:relative; height:64vh; cursor:grab; }+    /* Full-bleed: the board canvas owns the whole layout area — no centered+       "card" wrapper (that was a holdover from the small thumbnail-viewer page). */+    main { flex:1; display:flex; min-height:0; padding:0; }+    .card { background:transparent; border:0; border-radius:0; padding:0; box-shadow:none; max-width:none; width:100%; flex:1; display:flex; flex-direction:column; min-height:0; }+    #content { flex:1; display:flex; flex-direction:column; min-height:0; }+    .card .name, .card .meta, .card .desc { display:none; }   /* name + pad count already live in the top header */+    /* Full-bleed board canvas: no border, no rounding, edge-to-edge. */+    .svgbox { border:0; border-radius:0; background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%); overflow:hidden; position:relative; flex:1; min-height:0; cursor:grab; }+    /* ── left chip-fetcher library rail (in-flow: pushes the canvas, no overlay) ── */+    .bodyrow { display:flex; flex:1; min-height:0; min-width:0; }+    main { min-width:0; }+    #chiprail { display:flex; align-items:stretch; flex:0 0 auto; border-right:1px solid var(--border); background:var(--panel); }+    #chiprail.collapsed { align-items:center; }+    #chiprailToggle { display:none; align-items:center; gap:7px; background:var(--panel2); color:var(--muted); border:0; border-right:1px solid var(--border); padding:11px 8px; cursor:pointer; font-size:11.5px; font-weight:700; writing-mode:vertical-rl; letter-spacing:.6px; height:100%; }+    #chiprailToggle:hover { color:#fff; background:#1a2230; }+    #chiprailToggle .cr-arrow { writing-mode:horizontal-tb; }+    #chiprail.collapsed #chiprailToggle { display:flex; }+    #chiprail.collapsed #chiplistWrap { display:none; }+    #chiplistWrap { width:232px; background:var(--panel); display:flex; flex-direction:column; }+    .cr-head { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; border-bottom:1px solid var(--border); font-size:11px; font-weight:700; color:var(--muted); letter-spacing:.4px; text-transform:uppercase; }+    #chiprailClose { background:transparent; border:0; color:var(--muted); font-size:16px; cursor:pointer; padding:0 4px; line-height:1; }+    #chiprailClose:hover { color:#fff; }+    .cr-search, .cr-project { margin:7px 10px 2px; padding:5px 8px; background:#0a0e14; color:var(--text); border:1px solid var(--border); border-radius:7px; font-size:11.5px; }+    .cr-search:focus, .cr-project:focus { outline:none; border-color:var(--accent2); }+    .cr-project { margin-top:3px; cursor:pointer; }+    #chiplist { flex:1; overflow:auto; outline:none; }+    .cr-item { padding:7px 12px; cursor:pointer; font-size:12.5px; color:#aeb6c2; border-left:2px solid transparent; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }+    .cr-item:hover { background:rgba(0,184,177,.10); color:#fff; }+    .cr-item.cur { background:linear-gradient(90deg,rgba(0,184,177,.20),transparent); border-left-color:var(--accent2); color:#fff; font-weight:600; }+    .cr-hint { padding:7px 12px; border-top:1px solid var(--border); font-size:10px; color:var(--text3); }     .svgbox.grab { cursor:grabbing; }     .svgbox svg { position:absolute; left:0; top:0; width:100%; height:100%; transform-origin:0 0; display:block; }     .zoomtools { position:absolute; left:10px; top:10px; display:flex; gap:6px; z-index:5; }     .zoomtools button { width:30px; height:30px; border-radius:8px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; font-size:16px; cursor:pointer; display:grid; place-items:center; }     .zoomtools button:hover { border-color:var(--accent2); color:var(--accent2); }+    .zoomtools button:focus, .zoomtools button:focus-visible { outline:none; }     .panhint { position:absolute; right:10px; bottom:9px; color:var(--text3); font-size:11px; z-index:5; pointer-events:none; }     #svgbox [data-pad] { cursor:pointer; }     #svgbox [data-pad]:hover > * { stroke:#fff !important; stroke-width:0.12 !important; }@@ -101,6 +124,39 @@       box-shadow:0 12px 40px rgba(0,0,0,0.55); }     #tt.show { opacity:1; }     [data-tooltip] { cursor:help; }++    /* ── >NAME / >VALUE editable text labels (overlaid in footprint space) ── */+    .fp-textlabel { cursor:grab; }+    .fp-textlabel.dragging { cursor:grabbing; }+    .fp-textlabel text { paint-order:stroke; stroke:#0d1117; stroke-width:0.22; font-family:monospace; font-weight:700; user-select:none; }+    .fp-textlabel.ref text { fill:#36e08a; }+    .fp-textlabel.val text { fill:#e6b84a; }+    .fp-textlabel rect { fill:transparent; }+    .fp-textlabel.sel rect { fill:rgba(0,184,177,0.08); stroke:var(--accent2); stroke-width:0.05; }+    /* Text HUD controls */+    #textBody .tbtn { width:100%; box-sizing:border-box; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:8px; padding:7px; font-size:12.5px; cursor:pointer; }+    #textBody .tbtn:hover { border-color:var(--accent2); color:var(--accent2); }+    #textBody .tbtn-primary { background:linear-gradient(180deg,#10485f,#0e3346); color:#aef3ec; border-color:#1d6f7a; font-weight:700; margin-bottom:10px; }+    #textBody .trow { display:flex; align-items:center; gap:6px; margin:7px 0; font-size:12px; }+    #textBody .tlab { width:64px; display:flex; align-items:center; gap:5px; color:#cfd8e2; font-family:monospace; }+    #textBody .tdot { width:9px; height:9px; border-radius:2px; flex:0 0 9px; }+    #textBody .tdot.ref { background:#36e08a; } #textBody .tdot.val { background:#e6b84a; }+    #textBody label { display:flex; align-items:center; gap:3px; color:var(--text3); font-size:10px; }+    #textBody input { width:46px; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:3px 4px; font-size:11px; }+    #textBody input:focus { outline:none; border-color:var(--accent2); }+    #textBody .tnudge { display:flex; gap:2px; }+    #textBody .tnudge button { width:20px; height:22px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:5px; cursor:pointer; font-size:11px; line-height:1; }+    #textBody .tnudge button:hover { border-color:var(--accent2); color:var(--accent2); }+    #textBody .tstatus { margin-top:9px; font-size:11.5px; border-radius:6px; padding:6px 8px; }+    #textBody .tstatus.ok { background:rgba(54,224,138,0.10); color:#7ef0b6; border:1px solid rgba(54,224,138,0.3); }+    #textBody .tstatus.warn { background:rgba(230,160,74,0.12); color:#f0c477; border:1px solid rgba(230,160,74,0.35); }+    #textBody .ttoolrow { margin:0 0 10px; padding-bottom:9px; border-bottom:1px solid var(--border); }+    #textBody select#toolSel { flex:1; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:4px 6px; font-size:12px; cursor:pointer; }+    #textBody select#toolSel:focus { outline:none; border-color:var(--accent2); }+    #textBody .tterm { font-family:monospace; }+    #textBody .thistrow { display:flex; gap:6px; margin:8px 0 2px; }+    #textBody .tmini { flex:1; padding:5px; font-size:11.5px; }+    #textBody .tbtn:disabled { opacity:0.4; cursor:default; }   </style> </head> <body>@@ -122,7 +178,8 @@         <button class="sbtn" data-target="altium">Altium</button>         <button class="sbtn" data-target="orcad">OrCAD</button>       </span>-      <button class="layers-reopen" id="layersBtn" style="display:none" data-tooltip="Show the Layers panel — toggle copper, assembly, courtyard, paste apertures, and the InstaPCB solder-paste dispensing dots.">▦ Layers</button>+      <button class="layers-reopen" id="layersBtn" style="display:none" data-tooltip="Show the Layers panel — toggle copper, assembly, courtyard, paste apertures, and the 300µm solder-paste dots (off by default).">▦ Layers</button>+      <button class="layers-reopen" id="textBtn" style="display:none" data-tooltip="Edit the >NAME (RefDes) and >VALUE text placement — drag the labels on the board, nudge with arrows/inputs, or hit Auto-layout to snap them clear of the pads. Saved into the .kicad_mod so every tool (KiCad/Fusion/Altium/OrCAD) shows it.">⌖ Text</button>       <span class="conn" id="conn" data-tooltip="Live connection to the adom-footprint Rust server. Green = the UI is polling /state and the AI can drive this view in real time (push a new footprint, run /eval, read the console).">         <span class="dot"></span><span id="connText">connecting…</span>       </span>@@ -138,22 +195,76 @@     </div>     <div class="hud-body" id="layersBody"></div>     <div class="hud-foot">-      <b>InstaPCB</b> jets paste dot-by-dot — these 300&micro;m positions travel <i>with</i> the footprint across the Adom ecosystem.+      Solder-paste dispensing dots — <b>300&micro;m</b> each — computed for every footprint and carried <i>with</i> it across the Adom ecosystem. Off by default.+    </div>+  </div>++  <div class="hud" id="textHud" style="display:none; right:22px; bottom:30px; width:258px">+    <div class="hud-head" id="textHead">+      <span class="grip">⠿</span>+      <span class="htitle">Text placement</span>+      <button class="hbtn" id="textClose" data-tooltip="Hide (reopen from the ⌖ Text button).">✕</button>+    </div>+    <div class="hud-body" id="textBody">+      <div class="trow ttoolrow">+        <span class="tlab">Tool</span>+        <select id="toolSel" data-tooltip="Show the labels using your EDA tool's own wording — the placement geometry is identical, only the names differ. KiCad: Reference/Value · Fusion 360 / EAGLE: >NAME/>VALUE · Altium: Designator/Comment · OrCAD: RefDes/Value.">+          <option value="kicad">KiCad</option>+          <option value="fusion">Fusion 360 / EAGLE</option>+          <option value="altium">Altium</option>+          <option value="orcad">OrCAD</option>+        </select>+      </div>+      <button id="autoLayoutBtn" class="tbtn tbtn-primary" data-tooltip="Snap the RefDes above the top edge and the Value below the bottom edge, ~1 mm clear of the whole footprint (pads ∪ courtyard ∪ body).">✦ Auto-layout</button>+      <div class="thistrow">+        <button id="undoBtn" class="tbtn tmini" disabled data-tooltip="Undo the last placement change — Ctrl/Cmd+Z.">↶ Undo</button>+        <button id="redoBtn" class="tbtn tmini" disabled data-tooltip="Redo — Ctrl/Cmd+Shift+Z.">↷ Redo</button>+      </div>+      <div class="trow" data-which="reference">+        <span class="tlab"><span class="tdot ref"></span><span class="tterm"></span></span>+        <label>X<input type="number" step="0.1" class="tx"></label>+        <label>Y<input type="number" step="0.1" class="ty"></label>+        <span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>+      </div>+      <div class="trow" data-which="value">+        <span class="tlab"><span class="tdot val"></span><span class="tterm"></span></span>+        <label>X<input type="number" step="0.1" class="tx"></label>+        <label>Y<input type="number" step="0.1" class="ty"></label>+        <span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>+      </div>+      <div id="textStatus" class="tstatus"></div>+    </div>+    <div class="hud-foot">+      Drag a label on the board, or edit X/Y. Saved into the <code>.kicad_mod</code> — adom-lbr carries it to Fusion/Altium/OrCAD.     </div>   </div> -  <main>-    <div class="card" id="card">-      <div class="empty" id="empty">Waiting for a footprint… run <code>adom-footprint create</code> or the AI will push one here.</div>-      <div id="content" style="display:none">-        <div class="name" id="cName"></div>-        <div class="meta" id="cMeta"></div>-        <div class="desc" id="cDesc"></div>-        <div class="svgbox" id="svgbox"></div>+  <div class="bodyrow">+    <!-- Left chip-fetcher library rail. Collapsed by default; expand to browse /+         filter / arrow-nav the whole library. The AI can deep-link via ?mpn=. -->+    <div id="chiprail" class="collapsed" style="display:none">+      <button id="chiprailToggle" title="Chip library — expand to browse the chip-fetcher library (↑/↓)"><span class="cr-arrow">▸</span><span>Library</span></button>+      <div id="chiplistWrap">+        <div class="cr-head"><span id="cr-count">Library</span><button id="chiprailClose" title="Collapse">‹</button></div>+        <input id="cr-search" class="cr-search" placeholder="Filter chips…" autocomplete="off" spellcheck="false">+        <select id="cr-project" class="cr-project" autocomplete="off" title="Filter by chip-fetcher project"><option value="">All chips</option></select>+        <div id="chiplist" tabindex="0"></div>+        <div class="cr-hint">↑ / ↓ to switch · click to load · ?mpn= to deep-link</div>       </div>     </div>-  </main>-  <footer>rendered via service-kicad — standalone Rust, no local KiCad, no gallia</footer>+    <main>+      <div class="card" id="card">+        <div class="empty" id="empty">Waiting for a footprint… run <code>adom-footprint create</code>, open the Library, or the AI will push one here.</div>+        <div id="content" style="display:none">+          <div class="name" id="cName"></div>+          <div class="meta" id="cMeta"></div>+          <div class="desc" id="cDesc"></div>+          <div class="svgbox" id="svgbox"></div>+        </div>+      </div>+    </main>+  </div>+  <footer>rendered via service-kicad — standalone Rust, no local KiCad install</footer>    <div id="tt"></div>   <div id="toast"></div>@@ -207,6 +318,7 @@       document.getElementById('sendto').style.display=(has&&s.hasMod)?'inline-flex':'none';       setupLayers(s);       initPanZoom();+      setupTextEditor(s);     }     setInterval(pollState,1000); pollState(); @@ -249,7 +361,7 @@       {key:'courtyard', name:'Courtyard',        kind:'group', sel:'.adom-layer-courtyard', sw:'#FF26E2', ring:true},       {key:'drill',     name:'Drill',            kind:'group', sel:'.adom-layer-drill',     sw:'#888888', ring:true},       {key:'paste-ap',  name:'Paste apertures',  kind:'group', sel:'.adom-layer-paste-apertures', sw:'#8aa0b8', ring:true},-      {key:'paste-dots',name:'InstaPCB paste dots', kind:'group', sel:'.adom-layer-paste-dots', sw:'#37c8a6', special:true},+      {key:'paste-dots',name:'Paste dots', kind:'group', sel:'.adom-layer-paste-dots', sw:'#37c8a6', special:true},     ];     const LPREF='adomFpLayers';     function loadLayerState(){ try{ return JSON.parse(localStorage.getItem(LPREF))||{}; }catch{ return {}; } }@@ -286,23 +398,24 @@       btn.style.display='inline-flex';       const body=document.getElementById('layersBody'); body.innerHTML='';       LAYER_DEFS.forEach(def=>{-        // Standard layers default visible (unless turned off); the InstaPCB paste-dots-        // overlay is an opt-in preview → default OFF so it never obscures the copper.+        // Standard layers default visible (unless turned off); the paste-dots+        // overlay is opt-in → default OFF so it never obscures the copper (the+        // dots are always computed regardless — this only toggles their display).         const on = def.special ? (layerState[def.key]===true) : (layerState[def.key]!==false);         const meta = def.key==='paste-dots' ? ((s.pasteDots||0)+' @ 300µm')                   : def.key==='paste-ap'   ? ((s.pasteApertures||0)+' openings') : '';         const row=document.createElement('div');-        row.className='layer-row '+(on?'on':'off')+(def.special?' paste-dots':'');+        row.className='layer-row '+(on?'on':'off');         row.innerHTML='<span class="sw'+(def.ring?' ring':'')+'" style="'+(def.ring?('border-color:'+def.sw):('background:'+def.sw))+'"></span>'-          +'<span class="lname">'+def.name+(def.special?' <span style="font-size:9px;color:#37c8a6;border:1px solid #37c8a6;border-radius:3px;padding:0 3px;margin-left:2px;">NEW</span>':'')+'</span>'+          +'<span class="lname">'+def.name+'</span>'           +(meta?'<span class="lmeta">'+meta+'</span>':'')           +'<span class="chk">'+(on?'✓':'')+'</span>';         row.onclick=()=>{ const nowOn=!row.classList.contains('on'); row.classList.toggle('on',nowOn); row.classList.toggle('off',!nowOn); row.querySelector('.chk').textContent=nowOn?'✓':''; layerState[def.key]=nowOn; saveLayerState(layerState); applyLayer(def,nowOn); };         body.appendChild(row);         applyLayer(def,on); // apply current state to the freshly injected svg       });-      if(layerState.__open!==false && hud.style.display==='none'){ /* keep last open/closed */ }-      hud.style.display = (layerState.__closed? 'none':'flex');+      // Layers is the primary panel — always shown by default on load.+      hud.style.display = 'flex';     }     // Header buttons     document.getElementById('layersBtn').onclick=()=>{ const h=document.getElementById('layersHud'); h.style.display='flex'; layerState.__closed=false; saveLayerState(layerState); };@@ -352,6 +465,221 @@       m.onclick=e=>{if(e.target===m)m.classList.remove('show')};     } +    // ── >NAME / >VALUE text-placement editor ──────────────────────────────+    // Drag the labels on the board, edit X/Y, nudge, or Auto-layout. Saved into+    // the loaded .kicad_mod (POST /text) so delivery + export carry it.+    let _txt = { reference:null, value:null, bbox:null, sel:'reference', dirty:false };+    const _r2 = v => Math.round((v||0)*100)/100;+    function fpSvg(){ return document.querySelector('#svgbox svg'); }+    // Each EDA names the two texts differently — show the user's tool's wording+    // (the geometry/.kicad_mod is identical; reference→RefDes, value→Value).+    const TOOL_TERMS={ kicad:{ref:'Reference',val:'Value'}, fusion:{ref:'>NAME',val:'>VALUE'}, altium:{ref:'Designator',val:'Comment'}, orcad:{ref:'RefDes',val:'Value'} };+    function curTool(){ try{ return localStorage.getItem('adomFpTool')||'kicad'; }catch{ return 'kicad'; } }+    function terms(){ return TOOL_TERMS[curTool()]||TOOL_TERMS.kicad; }+    function updateHudTerms(){ const t=terms();+      [['reference',t.ref],['value',t.val]].forEach(([w,term])=>{+        const row=document.querySelector('#textBody .trow[data-which="'+w+'"]'); if(!row)return;+        const tt=row.querySelector('.tterm'); if(tt)tt.textContent=term;+        row.style.display=(_txt[w]&&_txt[w].hidden)?'none':'';   // hide the row for a hidden label+      });+      const s=document.getElementById('toolSel'); if(s)s.value=curTool(); }+    // Undo / redo for placement edits (Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z).+    let _undo=[], _redo=[], _txtName=null;+    function _snap(){ return { reference:_txt.reference?{..._txt.reference}:null, value:_txt.value?{..._txt.value}:null }; }+    function _pushUndo(before){ if(!before||(!before.reference&&!before.value))return; _undo.push(before); if(_undo.length>60)_undo.shift(); _redo=[]; _refreshUndoBtns(); }+    function _refreshUndoBtns(){ const u=document.getElementById('undoBtn'),r=document.getElementById('redoBtn'); if(u)u.disabled=!_undo.length; if(r)r.disabled=!_redo.length; }+    function _restore(s){ if(!s)return; if(s.reference)_txt.reference={...s.reference}; if(s.value)_txt.value={...s.value}; _txt.dirty=true; injectLabels(); syncInputs(); updateStatus(); applyText(); }+    function undoText(){ if(!_undo.length){ showToast('Nothing to undo'); return 'nothing to undo'; } _redo.push(_snap()); _restore(_undo.pop()); _refreshUndoBtns(); showToast('↶ Undid placement change'); return 'undone'; }+    function redoText(){ if(!_redo.length){ showToast('Nothing to redo'); return 'nothing to redo'; } _undo.push(_snap()); _restore(_redo.pop()); _refreshUndoBtns(); showToast('↷ Redid placement change'); return 'redone'; }++    function setupTextEditor(s){+      const btn=document.getElementById('textBtn'), hud=document.getElementById('textHud');+      if(!s.footprintName || !s.hasMod || !s.text){ btn.style.display='none'; hud.style.display='none'; return; }+      btn.style.display='inline-flex';+      if(s.footprintName!==_txtName){ _txtName=s.footprintName; _undo=[]; _redo=[]; }  // fresh part → fresh history+      if(!_txt.dirty){+        if(s.text.reference) _txt.reference={...s.text.reference};+        if(s.text.value) _txt.value={...s.text.value};+      }+      _txt.bbox = s.bbox || _txt.bbox;+      injectLabels(); updateHudTerms(); syncInputs(); updateStatus(); _refreshUndoBtns();+    }+    function injectLabels(){+      const sv=fpSvg(); if(!sv) return;+      sv.querySelectorAll('.fp-textlabel').forEach(e=>e.remove());+      const NS='http://www.w3.org/2000/svg', _t=terms();+      [['reference',_t.ref,_txt.reference],['value',_t.val,_txt.value]].forEach(([which,label,pos])=>{+        if(!pos || pos.hidden) return;   // don't draw hidden labels (vendor value at 0,0)+        const sz=Math.max(0.8,pos.size||1);+        const g=document.createElementNS(NS,'g'); g.setAttribute('class','fp-textlabel '+(which==='reference'?'ref':'val')); g.dataset.which=which;+        const w=label.length*0.62*sz, h=sz*1.5;+        const r=document.createElementNS(NS,'rect'); r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2); r.setAttribute('width',w); r.setAttribute('height',h); r.setAttribute('rx',0.2);+        const t=document.createElementNS(NS,'text'); t.setAttribute('x',pos.x); t.setAttribute('y',pos.y); t.setAttribute('font-size',sz); t.setAttribute('text-anchor','middle'); t.setAttribute('dominant-baseline','central'); t.textContent=label;+        g.appendChild(r); g.appendChild(t); sv.appendChild(g); wireDrag(g);+      });+      sv.querySelectorAll('.fp-textlabel').forEach(g=>g.classList.toggle('sel', g.dataset.which===_txt.sel));+      recomputeViewBox();+    }+    // Grow the SVG viewBox to enclose the footprint AND the (possibly far-flung)+    // text labels + a margin, so the labels never clip and "fit" shows everything.+    function recomputeViewBox(){+      const sv=fpSvg(); if(!sv||!_txt.bbox) return;+      let minx=_txt.bbox.minx, miny=_txt.bbox.miny, maxx=_txt.bbox.maxx, maxy=_txt.bbox.maxy;+      sv.querySelectorAll('.fp-textlabel text').forEach(t=>{ try{ const b=t.getBBox(); minx=Math.min(minx,b.x); miny=Math.min(miny,b.y); maxx=Math.max(maxx,b.x+b.width); maxy=Math.max(maxy,b.y+b.height); }catch(e){} });+      const px=(maxx-minx)*0.05+0.6, py=(maxy-miny)*0.05+0.6;+      sv.setAttribute('viewBox',(minx-px)+' '+(miny-py)+' '+(maxx-minx+2*px)+' '+(maxy-miny+2*py));+      sv.setAttribute('preserveAspectRatio','xMidYMid meet');+    }+    function clientToMm(e){+      const sv=fpSvg(); if(!sv||!sv.getScreenCTM) return null;+      const m=sv.getScreenCTM(); if(!m) return null;+      const pt=sv.createSVGPoint(); pt.x=e.clientX; pt.y=e.clientY;+      const p=pt.matrixTransform(m.inverse()); return {x:p.x,y:p.y};+    }+    function moveLabel(which){+      const sv=fpSvg(); if(!sv)return; const g=sv.querySelector('.fp-textlabel[data-which="'+which+'"]'); if(!g)return;+      const pos=_txt[which], t=g.querySelector('text'), r=g.querySelector('rect');+      t.setAttribute('x',pos.x); t.setAttribute('y',pos.y);+      const w=+r.getAttribute('width'), h=+r.getAttribute('height');+      r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2);+    }+    function wireDrag(g){+      const which=g.dataset.which;+      g.addEventListener('pointerdown',e=>{+        e.stopPropagation(); e.preventDefault();           // don't pan the board+        _txt.sel=which; injectLabels();+        const gg=fpSvg().querySelector('.fp-textlabel[data-which="'+which+'"]');+        gg.classList.add('dragging'); gg.setPointerCapture(e.pointerId);+        const start=clientToMm(e), orig={..._txt[which]}, before=_snap(); let moved=false;+        const move=ev=>{ const m=clientToMm(ev); if(!m||!start)return; _txt[which].x=_r2(orig.x+(m.x-start.x)); _txt[which].y=_r2(orig.y+(m.y-start.y)); moved=true; _txt.dirty=true; moveLabel(which); syncInputs(); updateStatus(); };+        const up=()=>{ gg.classList.remove('dragging'); gg.removeEventListener('pointermove',move); gg.removeEventListener('pointerup',up); if(moved) _pushUndo(before); recomputeViewBox(); applyText(); };+        gg.addEventListener('pointermove',move); gg.addEventListener('pointerup',up);+      });+    }+    function syncInputs(){+      document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{ const p=_txt[row.dataset.which]; if(!p)return; row.querySelector('.tx').value=p.x; row.querySelector('.ty').value=p.y; });+    }+    function updateStatus(){+      const st=document.getElementById('textStatus'), b=_txt.bbox, _t=terms();+      if(!b){ st.className='tstatus'; st.textContent=''; return; }+      const r=_txt.reference, v=_txt.value, rVis=r&&!r.hidden, vVis=v&&!v.hidden;+      if(!rVis&&!vVis){ st.className='tstatus'; st.textContent='Both labels are hidden in this footprint.'; return; }+      const bad=[];+      if(rVis && !(r.y<=b.miny)) bad.push(_t.ref);+      if(vVis && !(v.y>=b.maxy)) bad.push(_t.val);+      if(!bad.length){ st.className='tstatus ok'; st.textContent='✓ Clear of the footprint (keep-out top '+b.miny.toFixed(2)+' / bottom '+b.maxy.toFixed(2)+' mm)'; }+      else { st.className='tstatus warn'; st.textContent='⚠ '+bad.join(' & ')+' over the pads — Auto-layout to clear.'; }+    }+    function autoLayout(){+      const b=_txt.bbox; if(!b||!_txt.reference||!_txt.value)return;+      _pushUndo(_snap());+      _txt.reference.x=0; _txt.reference.y=_r2(b.miny-1);+      _txt.value.x=0; _txt.value.y=_r2(b.maxy+1);+      _txt.dirty=true; injectLabels(); syncInputs(); updateStatus(); applyText();+    }+    let _applyTimer=null;+    function applyText(){+      if(!_txt.reference||!_txt.value) return;+      clearTimeout(_applyTimer);+      _applyTimer=setTimeout(async()=>{+        try{ await fetch('text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reference:{x:_txt.reference.x,y:_txt.reference.y},value:{x:_txt.value.x,y:_txt.value.y}})}); _txt.dirty=false; showToast('✓ Placement saved into the .kicad_mod','ok'); }+        catch(e){ showToast('✕ Save failed: '+e,'err'); }+      },220);+    }+    // HUD wiring+    document.getElementById('textBtn').onclick=()=>{ document.getElementById('textHud').style.display='flex'; };+    document.getElementById('textClose').onclick=()=>{ document.getElementById('textHud').style.display='none'; };+    document.getElementById('autoLayoutBtn').onclick=autoLayout;+    document.getElementById('undoBtn').onclick=undoText;+    document.getElementById('redoBtn').onclick=redoText;+    document.getElementById('toolSel').addEventListener('change',e=>{ try{ localStorage.setItem('adomFpTool',e.target.value); }catch{} updateHudTerms(); injectLabels(); updateStatus(); });+    // Keyboard undo/redo (ignored while typing in an input/select).+    document.addEventListener('keydown',e=>{+      if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;+      const z=(e.key==='z'||e.key==='Z');+      if((e.ctrlKey||e.metaKey)&&z&&!e.shiftKey){ e.preventDefault(); undoText(); }+      else if((e.ctrlKey||e.metaKey)&&((e.key==='y'||e.key==='Y')||(z&&e.shiftKey))){ e.preventDefault(); redoText(); }+    });+    document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{+      const w=row.dataset.which;+      const onInput=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].x=_r2(parseFloat(row.querySelector('.tx').value)||0); _txt[w].y=_r2(parseFloat(row.querySelector('.ty').value)||0); _txt.dirty=true; _txt.sel=w; injectLabels(); updateStatus(); applyText(); };+      row.querySelector('.tx').addEventListener('change',onInput);+      row.querySelector('.ty').addEventListener('change',onInput);+      row.querySelectorAll('.tnudge button').forEach(btn=>btn.onclick=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].y=_r2(_txt[w].y+(btn.dataset.d==='up'?-0.5:0.5)); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); });+    });+    (function(){ const hud=document.getElementById('textHud'), head=document.getElementById('textHead'); let dx=0,dy=0,drag=false;+      head.addEventListener('pointerdown',e=>{ if(e.target.closest('.hbtn'))return; drag=true; const r=hud.getBoundingClientRect(); dx=e.clientX-r.left; dy=e.clientY-r.top; hud.style.right='auto'; hud.style.left=r.left+'px'; hud.style.top=r.top+'px'; head.setPointerCapture(e.pointerId); });+      head.addEventListener('pointermove',e=>{ if(!drag)return; let x=e.clientX-dx,y=e.clientY-dy; x=Math.max(6,Math.min(x,innerWidth-hud.offsetWidth-6)); y=Math.max(6,Math.min(y,innerHeight-40)); hud.style.left=x+'px'; hud.style.top=y+'px'; });+      head.addEventListener('pointerup',()=>drag=false);+    })();++    // ── left chip-fetcher library rail ──────────────────────────────────────+    // Lists every chip in the chip-fetcher library; click / ↑↓ / filter to load+    // any footprint. The AI deep-links via ?mpn=<MPN>; navigating updates the URL.+    function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }+    let chips=[], projects={}, projFilter='', search='', shown=[], chipIdx=-1, loadedMpn=null, navT=null, _urlChecked=false;+    const rail=document.getElementById('chiprail'), chiplist=document.getElementById('chiplist'), projSel=document.getElementById('cr-project'), crSearch=document.getElementById('cr-search');+    function applyFilter(){+      const q=search.trim().toLowerCase();+      const base=(projFilter&&projects[projFilter])?chips.filter(c=>(projects[projFilter].mpns||[]).includes(c.mpn)):chips.slice();+      shown=base.filter(c=>!q||c.mpn.toLowerCase().includes(q));+      chipIdx=shown.findIndex(c=>c.current);+    }+    function renderChips(){+      applyFilter();+      chiplist.innerHTML=shown.map(c=>'<div class="cr-item'+(c.current?' cur':'')+'" data-mpn="'+_esc(c.mpn)+'">'+_esc(c.mpn)+'</div>').join('')||'<div class="cr-hint" style="border:0">no matching chips</div>';+      document.getElementById('cr-count').textContent=(projFilter||'Library')+' · '+shown.length;+      const cur=chiplist.querySelector('.cr-item.cur'); if(cur)cur.scrollIntoView({block:'nearest'});+    }+    function renderProjectOptions(){+      const names=Object.keys(projects).map(n=>[n,(projects[n].mpns||[]).filter(m=>chips.some(c=>c.mpn===m)).length]).filter(a=>a[1]>0).sort((a,b)=>a[0].toLowerCase().localeCompare(b[0].toLowerCase()));+      projSel.innerHTML='<option value="">All chips ('+chips.length+')</option>'+names.map(a=>'<option value="'+_esc(a[0])+'">'+_esc(a[0])+' ('+a[1]+')</option>').join('');+      if(projFilter&&!names.some(a=>a[0]===projFilter))projFilter='';+      projSel.value=projFilter;+    }+    async function loadChips(){+      try{+        const [cj,pj]=await Promise.all([+          fetch('api/chips',{cache:'no-store'}).then(r=>r.json()),+          fetch('api/projects',{cache:'no-store'}).then(r=>r.json()).catch(()=>({projects:{}})),+        ]);+        chips=cj.chips||[]; projects=pj.projects||{};+        if(!chips.length){ rail.style.display='none'; return; }   // nothing to browse+        rail.style.display='';+        renderProjectOptions(); renderChips();+        if(!_urlChecked){ _urlChecked=true; const want=new URLSearchParams(location.search).get('mpn');+          if(want && chips.some(c=>c.mpn===want)){ const cur=(window._lastState&&window._lastState.mpn)||''; if(want!==cur) loadChipMpn(want); } }+      }catch(e){}+    }+    function setCurrent(mpn){ chips.forEach(c=>c.current=(c.mpn===mpn)); renderChips(); }+    function updateUrl(mpn){ try{ const u=new URL(location.href); u.searchParams.set('mpn',mpn); history.replaceState(null,'',u); }catch(e){} }+    async function loadChipMpn(mpn){+      if(!mpn||mpn===loadedMpn)return;+      const prev=loadedMpn; loadedMpn=mpn;+      try{ const r=await fetch('load-chip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn})});+        if(!r.ok){ loadedMpn=prev; showToast('✕ '+mpn+' not in library','err'); return; }+        _txt.dirty=false; updateUrl(mpn); pollState(); }+      catch(e){ loadedMpn=prev; }+    }+    // Highlight instantly; debounce the heavier load so ↑/↓ browsing only loads the chip you LAND on.+    function selectChip(mpn){ if(!mpn)return; setCurrent(mpn); clearTimeout(navT); navT=setTimeout(()=>{navT=null;loadChipMpn(mpn);},260); }+    function switchByOffset(d){ if(!shown.length)return; let i=chipIdx<0?(d>0?0:shown.length-1):chipIdx+d; i=Math.max(0,Math.min(shown.length-1,i)); selectChip(shown[i].mpn); }+    chiplist.addEventListener('click',e=>{const it=e.target.closest('.cr-item');if(it)selectChip(it.dataset.mpn);});+    projSel.addEventListener('change',()=>{projFilter=projSel.value;renderChips();chiplist.focus();});+    crSearch.addEventListener('input',()=>{search=crSearch.value;renderChips();});+    crSearch.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);} else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);} else if(e.key==='Enter'){e.preventDefault(); if(shown[chipIdx])loadChipMpn(shown[chipIdx].mpn); else if(shown[0])selectChip(shown[0].mpn);} });+    function setRail(open){ rail.classList.toggle('collapsed',!open); try{localStorage.setItem('adomFpChipRail',open?'1':'0')}catch(e){} if(open){loadChips();setTimeout(()=>crSearch.focus(),30);} }+    document.getElementById('chiprailToggle').onclick=()=>setRail(true);+    document.getElementById('chiprailClose').onclick=()=>setRail(false);+    (function(){let p=null;try{p=localStorage.getItem('adomFpChipRail')}catch(e){} setRail(p==='1');})();   // collapsed by default+    document.addEventListener('keydown',e=>{+      if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;+      if(rail.classList.contains('collapsed'))return;+      if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);}+      else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);}+    });+    loadChips();   // populate immediately (so ?mpn deep-link works + rail hides if empty)+     // ── AI-driving API (ai-driven-apps contract) — drive the real UI from the CLI ──     window.ui = {       layer(name, on){@@ -365,6 +693,16 @@       zoom(dir){ const b=document.getElementById(dir==='in'?'fpZin':dir==='out'?'fpZout':'fpFit'); if(b)b.click(); return 'zoom '+dir; },       send(target){ const b=document.querySelector('.sbtn[data-target="'+target+'"]'); if(b){ b.click(); return 'send to '+target; } return 'no send target: '+target; },       toast(m){ showToast(String(m)); return 'toast: '+m; },+      text(){ document.getElementById('textBtn').click(); return 'text editor open'; },+      textAuto(){ autoLayout(); return 'auto-layout: >NAME '+JSON.stringify(_txt.reference)+' >VALUE '+JSON.stringify(_txt.value); },+      textSet(which,x,y){ const w=(which==='value'||which==='>VALUE')?'value':'reference'; if(!_txt[w])return 'no '+w; _pushUndo(_snap()); if(x!=null)_txt[w].x=_r2(+x); if(y!=null)_txt[w].y=_r2(+y); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); return w+' → '+_txt[w].x+','+_txt[w].y; },+      textState(){ return {reference:_txt.reference, value:_txt.value, bbox:_txt.bbox, tool:curTool(), terms:terms()}; },+      textTool(t){ const v=['kicad','fusion','altium','orcad'].includes(t)?t:'kicad'; try{ localStorage.setItem('adomFpTool',v); }catch{} updateHudTerms(); injectLabels(); updateStatus(); return 'tool: '+v+' ('+terms().ref+' / '+terms().val+')'; },+      undo(){ return undoText(); },+      redo(){ return redoText(); },+      loadChip(mpn){ if(!mpn)return 'mpn required'; setCurrent(mpn); loadChipMpn(mpn); return 'loading '+mpn; },+      chips(){ return chips.map(c=>c.mpn); },+      library(open){ setRail(open!==false&&open!=='off'); return 'library '+(open!==false?'open':'closed'); },       state(){ return window._lastState||{}; },       list(){ return Object.keys(window.ui).filter(k=>typeof window.ui[k]==='function'); }     };
src/main.rs+68−2
@@ -18,7 +18,7 @@ use std::fs; use std::path::PathBuf;  #[derive(Parser)]-#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust, no gallia)")]+#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust)")] struct Cli {     #[command(subcommand)]     command: Cmd,@@ -207,12 +207,27 @@ fn run() -> Result<()> {             if let Some(f) = file {                 let content = fs::read_to_string(&f).with_context(|| format!("reading {}", f.display()))?;                 let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());-                let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;+                // Always bake the solder-paste dot group (same as /load) so the+                // paste-dots layer is present and toggleable in all cases.+                let svg = render::render_footprint_svg_with_paste(&content, bbox_from_mod(&content))?;+                // When launched on a chip-fetcher library chip+                // (<root>/<MPN>/<MPN>*.kicad_mod), wire the left library rail: mark+                // this chip current and set the root so the rail lists its siblings.+                let mut mpn = String::new();+                if let Some(dir) = f.parent() {+                    if let Some(dname) = dir.file_name().and_then(|s| s.to_str()) {+                        mpn = dname.to_string();+                    }+                    if let Some(root) = dir.parent() {+                        server::set_library_root(root.to_string_lossy().to_string());+                    }+                }                 server::set_state(server::AppState {                     footprint_name: name,                     svg,                     pad_count: content.matches("(pad ").count(),                     kicad_mod: content,+                    mpn,                     ..Default::default()                 });             }@@ -397,6 +412,57 @@ pub(crate) fn first_footprint_name(content: &str) -> Option<String> {     if name.is_empty() { None } else { Some(name.to_string()) } } +/// Current RefDes (`reference`/`>NAME`) and Value (`value`/`>VALUE`) text+/// positions + size, parsed from a `.kicad_mod` — fed to the live app so it can+/// render the draggable text labels at the right spot.+pub(crate) fn fp_text_state(km: &str) -> serde_json::Value {+    let one = |kind: &str| -> serde_json::Value {+        let at = Regex::new(&format!(+            r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+-?[\d.]+)?\)"#+        )).unwrap();+        match at.captures(km) {+            Some(c) => {+                let x: f64 = c[1].parse().unwrap_or(0.0);+                let y: f64 = c[2].parse().unwrap_or(0.0);+                let size = Regex::new(&format!(r#"\(fp_text {kind}\b[\s\S]*?\(size\s+(-?[\d.]+)"#))+                    .unwrap()+                    .captures(km)+                    .and_then(|m| m[1].parse::<f64>().ok())+                    .unwrap_or(1.0);+                // Hidden? Look for a `hide` keyword within this fp_text's block (up+                // to the next fp_text/pad) — common in vendor footprints (value hidden+                // at 0,0). The editor skips hidden labels so they don't garble.+                let start = c.get(0).map(|m| m.start()).unwrap_or(0);+                let tail = &km[start..];+                let end = tail+                    .get(1..)+                    .and_then(|t| t.find("(fp_text ").map(|i| i + 1))+                    .or_else(|| tail.find("\n  (pad "))+                    .unwrap_or_else(|| tail.len().min(400));+                let hidden = tail[..end.min(tail.len())].contains(" hide");+                serde_json::json!({ "x": x, "y": y, "size": size, "hidden": hidden })+            }+            None => serde_json::Value::Null,+        }+    };+    serde_json::json!({ "reference": one("reference"), "value": one("value") })+}++/// Rewrite the `reference`/`value` fp_text `(at x y)` positions in a `.kicad_mod`+/// (rotation dropped → horizontal, per the placement rule). Other text untouched.+pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f64) -> String {+    let r2 = |v: f64| (v * 100.0).round() / 100.0;+    let mut out = km.to_string();+    for (kind, x, y) in [("reference", r2(refx), r2(refy)), ("value", r2(valx), r2(valy))] {+        let re = Regex::new(&format!(+            r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+(?:\s+-?[\d.]+)?(\s*\))"#+        )).unwrap();+        let rep = format!("${{1}}{x} {y}${{2}}");+        out = re.replace(&out, rep.as_str()).into_owned();+    }+    out+}+ /// Bounding box (minx,miny,maxx,maxy) from pad + fp_rect extents in a .kicad_mod. pub(crate) fn bbox_from_mod(content: &str) -> (f64, f64, f64, f64) {     let mut xs = Vec::new();
src/server.rs+160
@@ -20,9 +20,15 @@ pub struct AppState {     pub tags: String,     /// Raw `.kicad_mod` retained for "Send to" delivery (empty if loaded via SVG).     pub kicad_mod: String,+    /// Library folder name (the chip's `<MPN>/` dir) — what the left library rail+    /// highlights as "current". Empty when not loaded from a chip-fetcher library.+    pub mpn: String, }  static STATE: Mutex<Option<AppState>> = Mutex::new(None);+/// Root of the chip-fetcher library (a folder of `<MPN>/` dirs) — powers the left+/// library rail. Set from `serve --file <…/MPN/MPN.kicad_mod>`, else auto-detected.+static LIBRARY_ROOT: Mutex<Option<String>> = Mutex::new(None); static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new()); static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None); static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);@@ -35,16 +41,125 @@ pub fn set_state(s: AppState) {     *STATE.lock().unwrap() = Some(s); } +pub fn set_library_root(p: String) {+    *LIBRARY_ROOT.lock().unwrap() = Some(p);+}++/// The library root: the explicitly-set one (from `serve --file`), else the first+/// chip-fetcher library we can find on disk — so the rail + `?mpn=` work even when+/// the app was launched without a file.+fn library_root() -> Option<String> {+    if let Some(r) = LIBRARY_ROOT.lock().unwrap().clone() {+        return Some(r);+    }+    let home = std::env::var("HOME").unwrap_or_default();+    for c in [+        format!("{home}/project/chip-fetcher/library"),+        "chip-fetcher/library".to_string(),+        "/home/adom/project/chip-fetcher/library".to_string(),+    ] {+        if std::path::Path::new(&c).is_dir() {+            return Some(c);+        }+    }+    None+}++/// Pick the footprint file to load for a chip: prefer the canonical `<mpn>.kicad_mod`+/// (what adom-lbr exports from), then the Fusion/Altium variants, then any `.kicad_mod`.+fn footprint_file_for(dir: &std::path::Path, mpn: &str) -> Option<std::path::PathBuf> {+    for cand in [+        format!("{mpn}.kicad_mod"),+        format!("{mpn}.fusion.kicad_mod"),+        format!("{mpn}.altium.kicad_mod"),+        format!("{mpn}.manufacturer.kicad_mod"),+    ] {+        let p = dir.join(&cand);+        if p.is_file() {+            return Some(p);+        }+    }+    std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find(|p| {+        p.extension().map(|x| x == "kicad_mod").unwrap_or(false)+    })+}++/// Every chip in the library (a `<MPN>/` dir with at least one `.kicad_mod`),+/// sorted, with the currently-loaded one flagged. Powers the left library rail.+fn list_chips() -> Vec<(String, bool)> {+    let root = match library_root() {+        Some(r) => r,+        None => return vec![],+    };+    let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();+    let mut out = Vec::new();+    if let Ok(rd) = std::fs::read_dir(&root) {+        for e in rd.flatten() {+            let p = e.path();+            if !p.is_dir() {+                continue;+            }+            let name = e.file_name().to_string_lossy().to_string();+            if footprint_file_for(&p, &name).is_some() {+                let is_cur = name == cur;+                out.push((name, is_cur));+            }+        }+    }+    out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));+    out+}++/// Load a chip's footprint from the library into app state (rendered with the+/// solder-paste group baked, like /load). Returns false if not found.+fn load_chip(mpn: &str) -> bool {+    let root = match library_root() {+        Some(r) => r,+        None => return false,+    };+    let dir = std::path::Path::new(&root).join(mpn);+    let file = match footprint_file_for(&dir, mpn) {+        Some(f) => f,+        None => return false,+    };+    let content = match std::fs::read_to_string(&file) {+        Ok(c) => c,+        Err(_) => return false,+    };+    let name = crate::first_footprint_name(&content).unwrap_or_else(|| mpn.to_string());+    match crate::render::render_footprint_svg_with_paste(&content, crate::bbox_from_mod(&content)) {+        Ok(svg) => {+            set_state(AppState {+                footprint_name: name,+                svg,+                pad_count: content.matches("(pad ").count(),+                description: String::new(),+                tags: String::new(),+                kicad_mod: content,+                mpn: mpn.to_string(),+            });+            true+        }+        Err(_) => false,+    }+}+ fn state_json() -> String {     match &*STATE.lock().unwrap() {         Some(s) => {             let aps = crate::paste::paste_apertures(&s.kicad_mod);             let dot_count = crate::paste::paste_overlay_svg(&aps, 0.0, 0.0).2;+            let (minx, miny, maxx, maxy) = crate::bbox_from_mod(&s.kicad_mod);             json!({                 "footprintName": s.footprint_name, "svg": s.svg, "padCount": s.pad_count,                 "description": s.description, "tags": s.tags, "hasMod": !s.kicad_mod.is_empty(),+                "mpn": s.mpn,                 "pasteApertures": aps.len(),                 "pasteDots": dot_count,+                // For the >NAME/>VALUE editor: current text positions + the+                // keep-out bbox (pads ∪ rects) the Auto-layout button clears.+                "bbox": { "minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy },+                "text": crate::fp_text_state(&s.kicad_mod),             })             .to_string()         }@@ -112,6 +227,30 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec         (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),         (Method::Get, "/favicon.svg") => svg(FAVICON),         (Method::Get, "/state") => json_resp(&state_json()),+        // Left library rail: list every chip in the chip-fetcher library, mirror its+        // project groupings, and load a chosen chip's footprint.+        (Method::Get, "/api/chips") => {+            let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({ "mpn": m, "current": c })).collect();+            let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();+            json_resp(&json!({ "chips": arr, "current": cur }).to_string())+        }+        (Method::Get, "/api/projects") => {+            let home = std::env::var("HOME").unwrap_or_default();+            let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");+            json_resp(&std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string()))+        }+        (Method::Post, "/load-chip") => {+            let mpn = serde_json::from_str::<Value>(body).ok()+                .and_then(|v| v.get("mpn").and_then(|x| x.as_str()).map(String::from))+                .unwrap_or_default();+            if mpn.is_empty() {+                json_err(400, "mpn required")+            } else if load_chip(&mpn) {+                json_resp(&json!({ "ok": true, "mpn": mpn }).to_string())+            } else {+                json_err(404, "chip not found in library")+            }+        }         (Method::Post, "/load") => {             if let Ok(v) = serde_json::from_str::<Value>(body) {                 // Preferred: push raw .kicad_mod; the server renders it via@@ -135,6 +274,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec                             description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),                             tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),                             kicad_mod: km.to_string(),+                            mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(),                         }),                         Err(e) => return json_err(422, &e.to_string()),                     }@@ -146,6 +286,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec                         description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),                         tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),                         kicad_mod: v.get("kicadMod").and_then(|x| x.as_str()).unwrap_or("").to_string(),+                        mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(),                     });                 }             }@@ -194,6 +335,25 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             };             json_resp(&crate::deliver::deliver(&name, &km, &target).to_string())         }+        // Bake new >NAME/>VALUE positions into the loaded .kicad_mod so delivery+        // and export carry the placement the user set in the editor.+        (Method::Post, "/text") => {+            let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));+            let num = |k: &str, sub: &str| v.get(k).and_then(|o| o.get(sub)).and_then(|n| n.as_f64());+            match (num("reference", "x"), num("reference", "y"), num("value", "x"), num("value", "y")) {+                (Some(rx), Some(ry), Some(vx), Some(vy)) => {+                    let mut st = STATE.lock().unwrap();+                    match st.as_mut() {+                        Some(s) if !s.kicad_mod.is_empty() => {+                            s.kicad_mod = crate::rewrite_fp_text(&s.kicad_mod, rx, ry, vx, vy);+                            json_resp(&json!({ "ok": true, "text": crate::fp_text_state(&s.kicad_mod) }).to_string())+                        }+                        _ => json_err(422, "no .kicad_mod loaded to edit"),+                    }+                }+                _ => json_err(400, "need reference.{x,y} and value.{x,y}"),+            }+        }         (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),         _ if url.starts_with("/eval/") => {             let rest = &url["/eval/".len()..];