@floless/app 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53093,7 +53093,7 @@ function appVersion() {
53093
53093
  return resolveVersion({
53094
53094
  isSea: isSea2(),
53095
53095
  sqVersionXml: readSqVersionXml(),
53096
- define: true ? "0.82.0" : void 0,
53096
+ define: true ? "0.83.0" : void 0,
53097
53097
  pkgVersion: readPkgVersion()
53098
53098
  });
53099
53099
  }
@@ -53103,7 +53103,7 @@ function resolveChannel(s) {
53103
53103
  return "dev";
53104
53104
  }
53105
53105
  function appChannel() {
53106
- return resolveChannel({ isSea: isSea2(), define: true ? "0.82.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.83.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -53652,6 +53652,7 @@ var import_node_path14 = require("node:path");
53652
53652
  var ROOT2 = process.env.FLOLESS_HOME ?? (0, import_node_path14.join)((0, import_node_os11.homedir)(), ".floless");
53653
53653
  var TEMPLATES_FILE = (0, import_node_path14.join)(ROOT2, "templates.json");
53654
53654
  var REQUESTS_DIR = (0, import_node_path14.join)(ROOT2, "requests");
53655
+ var STEEL_FAVS_FILE = (0, import_node_path14.join)(ROOT2, "steel-connections.json");
53655
53656
  function ensureRoot() {
53656
53657
  if (!(0, import_node_fs16.existsSync)(ROOT2)) (0, import_node_fs16.mkdirSync)(ROOT2, { recursive: true });
53657
53658
  }
@@ -53716,6 +53717,56 @@ function updateTemplate(id, patch2) {
53716
53717
  writeTemplates(list);
53717
53718
  return cur;
53718
53719
  }
53720
+ function listSteelFavourites() {
53721
+ if (!(0, import_node_fs16.existsSync)(STEEL_FAVS_FILE)) return [];
53722
+ try {
53723
+ const parsed = JSON.parse((0, import_node_fs16.readFileSync)(STEEL_FAVS_FILE, "utf8"));
53724
+ return Array.isArray(parsed) ? parsed : [];
53725
+ } catch {
53726
+ return [];
53727
+ }
53728
+ }
53729
+ function writeSteelFavourites(list) {
53730
+ ensureRoot();
53731
+ const tmp = `${STEEL_FAVS_FILE}.${process.pid}.tmp`;
53732
+ (0, import_node_fs16.writeFileSync)(tmp, JSON.stringify(list, null, 2));
53733
+ (0, import_node_fs16.renameSync)(tmp, STEEL_FAVS_FILE);
53734
+ }
53735
+ function addSteelFavourite(input) {
53736
+ const list = listSteelFavourites();
53737
+ const fav = {
53738
+ id: (0, import_node_crypto5.randomUUID)(),
53739
+ name: input.name.trim(),
53740
+ category: (input.category || "Uncategorized").trim(),
53741
+ kind: input.kind,
53742
+ ...input.params ? { params: input.params } : {},
53743
+ ...input.geometry ? { geometry: input.geometry } : {},
53744
+ schemaVersion: typeof input.schemaVersion === "number" ? input.schemaVersion : 1,
53745
+ ...input.emittedBy ? { emittedBy: input.emittedBy } : {},
53746
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
53747
+ };
53748
+ list.push(fav);
53749
+ writeSteelFavourites(list);
53750
+ return fav;
53751
+ }
53752
+ function deleteSteelFavourite(id) {
53753
+ const list = listSteelFavourites();
53754
+ const next = list.filter((f) => f.id !== id);
53755
+ if (next.length === list.length) return false;
53756
+ writeSteelFavourites(next);
53757
+ return true;
53758
+ }
53759
+ function updateSteelFavourite(id, patch2) {
53760
+ const list = listSteelFavourites();
53761
+ const cur = list.find((f) => f.id === id);
53762
+ if (!cur) return null;
53763
+ const name = patch2.name?.trim();
53764
+ const category = patch2.category?.trim();
53765
+ cur.name = name || cur.name;
53766
+ cur.category = category || cur.category;
53767
+ writeSteelFavourites(list);
53768
+ return cur;
53769
+ }
53719
53770
  function addRequest(req, decoded = []) {
53720
53771
  ensureRoot();
53721
53772
  if (!(0, import_node_fs16.existsSync)(REQUESTS_DIR)) (0, import_node_fs16.mkdirSync)(REQUESTS_DIR, { recursive: true });
@@ -65992,6 +66043,49 @@ async function startServer() {
65992
66043
  broadcast({ type: "templates-changed" });
65993
66044
  return { ok: true };
65994
66045
  });
66046
+ app.get("/api/steel-favourites", async () => ({ ok: true, favourites: listSteelFavourites() }));
66047
+ app.post("/api/steel-favourites", async (req, reply) => {
66048
+ const { name, category, kind, params, geometry, schemaVersion, emittedBy } = req.body ?? {};
66049
+ if (!name || !name.trim()) return reply.status(400).send({ ok: false, error: "name required" });
66050
+ if (kind !== "base-plate" && kind !== "shear-plate" && kind !== "custom") {
66051
+ return reply.status(400).send({ ok: false, error: "valid kind required" });
66052
+ }
66053
+ if (kind === "custom" ? !Array.isArray(geometry) || geometry.length === 0 : params == null || typeof params !== "object") {
66054
+ return reply.status(400).send({
66055
+ ok: false,
66056
+ error: kind === "custom" ? "geometry required for a custom favourite" : "params required for a recipe favourite"
66057
+ });
66058
+ }
66059
+ const favourite = addSteelFavourite({
66060
+ name,
66061
+ category,
66062
+ kind,
66063
+ params,
66064
+ geometry,
66065
+ schemaVersion,
66066
+ emittedBy
66067
+ });
66068
+ broadcast({ type: "steel-favourites-changed" });
66069
+ return { ok: true, favourite };
66070
+ });
66071
+ app.patch(
66072
+ "/api/steel-favourites/:id",
66073
+ async (req, reply) => {
66074
+ const { name, category } = req.body ?? {};
66075
+ if ((name == null || !name.trim()) && (category == null || !category.trim())) {
66076
+ return reply.status(400).send({ ok: false, error: "name or category required" });
66077
+ }
66078
+ const favourite = updateSteelFavourite(req.params.id, { name, category });
66079
+ if (!favourite) return reply.status(404).send({ ok: false, error: "favourite not found" });
66080
+ broadcast({ type: "steel-favourites-changed" });
66081
+ return { ok: true, favourite };
66082
+ }
66083
+ );
66084
+ app.delete("/api/steel-favourites/:id", async (req, reply) => {
66085
+ if (!deleteSteelFavourite(req.params.id)) return reply.status(404).send({ ok: false, error: "favourite not found" });
66086
+ broadcast({ type: "steel-favourites-changed" });
66087
+ return { ok: true };
66088
+ });
65995
66089
  app.get("/api/requests", async () => ({ ok: true, requests: listRequests() }));
65996
66090
  app.get("/api/requests/:id/snapshot/:n", async (req, reply) => {
65997
66091
  const n = Number.parseInt(req.params.n, 10);
@@ -2490,7 +2490,7 @@ function onDown(e) {
2490
2490
  if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
2491
2491
  if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
2492
2492
  if (basePickCol) { e.stopPropagation(); const r = basePickPoint(e.clientX, e.clientY); if (r) { if (api && api.onBasePick) api.onBasePick(r); setBasePickMode(null); } return; } // Mode B: commit + disarm only on a valid on-column pick; an off-column click is ignored (stays armed) — Esc cancels
2493
- if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the queued detail at the pick, then disarm (one shot)
2493
+ if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); const sticky = insertPending && insertPending.sticky; if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); if (!sticky) setInsertMode(false); return; } // armed: place at the pick. One-shot for a detail / IFC import; a sticky pending (a favourite) STAYS armed for repeat placement — Esc/onInsertPlace's own cancel disarms.
2494
2494
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
2495
2495
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
2496
2496
  if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
@@ -94,7 +94,7 @@
94
94
  g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
95
95
  text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
96
96
  g.cohot:hover text.cotx{opacity:1}
97
- #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
97
+ #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal,#favConnModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
98
98
  #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
99
99
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
100
100
  .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
@@ -419,8 +419,47 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
419
419
  #m3dLegend .m3dbody.on{display:flex;flex:1 1 auto} /* the active body fills the capped panel and scrolls (min-height:0 lets it shrink below content) */
420
420
  #m3dLegendBody{padding:8px 10px}
421
421
  #m3dViewsBody,#m3dFavBody{padding:8px 10px}
422
- /* Favourites placeholder (a later slice wires its content). */
423
- #m3dFavBody .favsoon{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
422
+ /* ── Favourites tab (#m3dFavBody) the Views-tab row/search recipe. No create button (★ lives in the
423
+ Inspector); a kind-filter + search header, per-row kind swatch + portability badge + row-click-to-arm. ── */
424
+ #favHeadRow{display:flex;align-items:stretch;gap:6px;margin-bottom:6px;flex:none}
425
+ #favKindFilter{flex:1;min-width:0;height:28px}
426
+ #favKindFilter button{flex:1;background:transparent;color:var(--mut);font-size:10px;padding:0 2px;box-shadow:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
427
+ #favKindFilter button.on{background:var(--brand);color:#fff}
428
+ #favSearchTog{flex:none;width:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--mut);border:1px solid var(--line);border-radius:6px;background:transparent;cursor:pointer}
429
+ #favSearchTog.on{color:var(--brand);border-color:var(--brand)}
430
+ #favSearchTog svg{display:block}
431
+ #favSearch{display:none;align-items:center;gap:6px;height:26px;margin-bottom:6px;padding:0 8px;background:var(--bg);border:1px solid var(--line);border-radius:6px;flex:none}
432
+ #favSearch.show{display:flex}
433
+ #favSearch:focus-within{border-color:var(--brand)}
434
+ #favSearch .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
435
+ #favSearch input{flex:1;min-width:0;width:auto;height:auto;background:transparent;border:0;outline:none;color:var(--text);font:12px system-ui;padding:0}
436
+ #favSearch input::placeholder{color:var(--mut)}
437
+ #favSearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden}
438
+ #favSearch.has .lsx{visibility:visible}
439
+ #favSearch .lsx:hover{color:#fecaca;background:#7f1d1d}
440
+ #m3dFavBody .favrow{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:5px;cursor:pointer;user-select:none;white-space:nowrap}
441
+ #m3dFavBody .favrow:hover{background:#33415580}
442
+ #m3dFavBody .favrow:focus-visible{outline:1px solid var(--brand);outline-offset:-1px}
443
+ #m3dFavBody .favrow.armed{box-shadow:inset 2px 0 0 var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
444
+ #m3dFavBody .favsw{flex:none;width:11px;height:11px;border-radius:2px;background:var(--sw,#8a97a8);border:1px solid rgba(0,0,0,.35)}
445
+ #m3dFavBody .favname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
446
+ #m3dFavBody .favbadge{flex:none;font-size:10px;line-height:1.4;padding:0 5px;border-radius:4px;border:1px solid var(--line);color:var(--mut)}
447
+ #m3dFavBody .favbadge.portable{border-color:var(--brand);color:#bfdbfe}
448
+ #m3dFavBody .favrow input.favedit{flex:1;min-width:0;font:12px system-ui;background:#0b1220;color:var(--text);border:1px solid var(--brand);border-radius:4px;padding:0 4px;outline:none}
449
+ #m3dFavBody .favrow .vx{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;visibility:hidden;font-size:13px;line-height:1}
450
+ #m3dFavBody .favrow:hover .vx,#m3dFavBody .favrow:focus-within .vx{visibility:visible}
451
+ #m3dFavBody .favrow .vx:hover{color:#fecaca;background:#7f1d1d}
452
+ #m3dFavBody .favrow .vdots{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;font-size:14px;line-height:1;cursor:pointer}
453
+ #m3dFavBody .favrow .vdots:hover{color:var(--text);background:#334155}
454
+ #m3dFavBody .favempty{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
455
+ #m3dFavBody .favnores{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
456
+ #m3dFavBody .favnores .pilllink{margin-left:4px}
457
+ #favRowMenu{position:fixed;left:0;top:0;z-index:45;min-width:184px}
458
+ .ghostw.faved{color:var(--brand);border-color:var(--brand)} /* the ★ Save button once this recipe is in the library */
459
+ /* #favConnModal — a small sibling modal (askAiModal sizing); Kind is a read-only chip row, not an input. */
460
+ #favConnModal .mpanel{width:min(460px,92vw)}
461
+ #favConnModal .favkindfield{display:flex;align-items:center;gap:8px;background:#0f172a;border:1px solid #475569;border-radius:6px;padding:6px 8px;min-height:34px}
462
+ #favConnModal .favkindfield .chip{margin:0}
424
463
  #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
425
464
  #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:default;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap} /* only the left-hand box toggles now (it sets cursor:pointer); the row still isolates on dbl-click + right-clicks for the menu */
426
465
  #m3dLegend .lrow:hover{background:#33415580}
@@ -716,7 +755,7 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
716
755
  </div>
717
756
  <div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
718
757
  <div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
719
- <div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites><div class=favsoon>Favourite connections — coming soon</div></div>
758
+ <div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites></div>
720
759
  </div>
721
760
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
722
761
  <div id=m3dAxes></div>
@@ -811,6 +850,24 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
811
850
  </div>
812
851
  </div>
813
852
  </div>
853
+ <div id=favConnModal><div class=mbackdrop id=favBackdrop></div>
854
+ <div class=mpanel>
855
+ <div class=mhead><b id=favModalTitle>Save to Favourites</b><button id=favClose data-tip="Close">✕</button></div>
856
+ <div style="padding:14px;display:flex;flex-direction:column;gap:4px">
857
+ <label class=elab for=favName>Name</label>
858
+ <input id=favName placeholder="e.g. Standard 4-bolt base plate" autocomplete=off>
859
+ <div class=elab>Kind</div>
860
+ <div class=favkindfield role=group aria-label=Kind><span id=favKind class=chip></span></div>
861
+ <label class=elab for=favCat>Category</label>
862
+ <input id=favCat placeholder="Uncategorized" autocomplete=off list=favCatSuggest>
863
+ <datalist id=favCatSuggest></datalist>
864
+ <div id=favHint class=hint style="margin-top:6px"></div>
865
+ <div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:6px">
866
+ <button id=favCancel class=ghost>Cancel</button>
867
+ <button id=favSave>Save to Favourites</button>
868
+ </div>
869
+ </div>
870
+ </div></div>
814
871
  <div id=lightbox><div class=mbackdrop id=lbBackdrop></div>
815
872
  <div class=lbpanel><div class=mhead><b id=lbCap></b><div style="display:flex;gap:10px;align-items:center"><span class=hint>scroll = zoom · drag = pan · dbl-click = reset</span><button id=lbClose>✕</button></div></div><div id=lbView><img id=lbImg></div></div></div>
816
873
  <script>
@@ -1822,13 +1879,15 @@ function panel(){
1822
1879
  <div class="row f" style="gap:6px;flex-wrap:wrap">
1823
1880
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
1824
1881
  <button class=danger id=cmpDel data-tip="Remove this whole imported connection">Delete connection</button>
1882
+ <button class=ghostw id=cmpFav data-tip="Save this imported geometry to your Favourites — drops in place, will not re-fit">★ Save to Favourites</button>
1825
1883
  </div>`;
1826
1884
  {const b=document.getElementById('cmpMember');if(b)b.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};}
1827
1885
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1828
1886
  {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1887
+ {const b=document.getElementById('cmpFav');if(b)b.onclick=()=>openFavModalFor(j);}
1829
1888
  return;
1830
1889
  }
1831
- const isBP=j.kind==='base-plate',pp=j.params||{};
1890
+ const isBP=j.kind==='base-plate',pp=j.params||{};const savedFav=favMatch(j);
1832
1891
  const plate=(partsById||{})[cs.conn+':plate']||null;
1833
1892
  const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
1834
1893
  const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
@@ -1848,6 +1907,7 @@ function panel(){
1848
1907
  ${isBP?`<button class=ghostw id=cmpTrim data-tip="Pick a point on ${esc(j.main)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button>`:''}
1849
1908
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1850
1909
  <button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
1910
+ <button class="ghostw${savedFav?' faved':''}" id=cmpFav data-tip="${savedFav?'Edit this favourite’s name or category':('Save this connection to your Favourites — reusable on any '+(isBP?'column':'beam'))}">${savedFav?'★ Saved to Favourites':'★ Save to Favourites'}</button>
1851
1911
  </div>`;
1852
1912
  const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1853
1913
  {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
@@ -1855,6 +1915,7 @@ function panel(){
1855
1915
  {const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
1856
1916
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1857
1917
  {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1918
+ {const b=document.getElementById('cmpFav');if(b)b.onclick=()=>openFavModalFor(j);}
1858
1919
  return;
1859
1920
  }}
1860
1921
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
@@ -3044,6 +3105,7 @@ addEventListener('keydown',e=>{
3044
3105
  if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
3045
3106
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
3046
3107
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
3108
+ if(e.key==='Escape'&&favModalIsOpen()){favModalClose();return;}
3047
3109
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
3048
3110
  if(e.key==='Escape'&&connLibOpen()){closeConnLib();return;}
3049
3111
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
@@ -3222,7 +3284,7 @@ const view3dApi={
3222
3284
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
3223
3285
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
3224
3286
  onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
3225
- onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
3287
+ onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';} if(!on)favDisarmed();}, // armed → cancel target; disarm also clears any armed favourite (the pinned bar + the .armed row)
3226
3288
  onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
3227
3289
  onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
3228
3290
  const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
@@ -3241,6 +3303,39 @@ const view3dApi={
3241
3303
  toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
3242
3304
  },
3243
3305
  onInsertPlace:(pick,pending)=>{
3306
+ if(pending&&pending.kind==='favourite'&&pending.favourite){const f=pending.favourite;
3307
+ // Slice F1: apply a saved favourite. base-plate → a column; shear-plate → the nearest beam end; custom → drop the
3308
+ // mesh at the pick. Wrong target → a corrective toast + no-op (the sticky arm stays on for a retry). Reuses the
3309
+ // v0.78 recipe-bake path (edit() + pendingConnSel select the new whole connection on the next rebuild).
3310
+ if(f.kind==='base-plate'){
3311
+ const col=pick.anchorId?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
3312
+ if(!col){toast('Click a column to place this base plate');return;}
3313
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3314
+ const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},f.params||{}),source:'user'};
3315
+ pendingConnSel=id;const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
3316
+ edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
3317
+ toast((had?'Base plate on '+col.id+' replaced with “'+f.name+'”':'“'+f.name+'” applied to '+col.id)+' — click another column, or Esc');return;
3318
+ }
3319
+ if(f.kind==='shear-plate'){
3320
+ const beam=pick.anchorId?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='beam'):null;
3321
+ if(!beam){toast('Click a beam end to place this shear plate');return;}
3322
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3323
+ const g=partsById[beam.id],d3=(a,b)=>a&&b?Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2]):Infinity;
3324
+ const endIdx=(g&&g.from&&g.to&&d3(pick.point,g.to)<d3(pick.point,g.from))?1:0;
3325
+ const joint={id,kind:'shear-plate',main:beam.id,at:'end'+endIdx,params:Object.assign({},f.params||{}),source:'user'};
3326
+ pendingConnSel=id;const had=(C.joints||[]).some(x=>x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx);
3327
+ edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx));C.joints.push(joint);selIds=new Set();});
3328
+ toast((had?'Shear plate on '+beam.id+' '+(endIdx?'end':'start')+' replaced with “'+f.name+'”':'“'+f.name+'” applied to '+beam.id+' '+(endIdx?'end':'start'))+' — click another beam end, or Esc');return;
3329
+ }
3330
+ if(f.kind==='custom'&&Array.isArray(f.geometry)&&f.geometry.length){
3331
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3332
+ const joint={id,kind:'custom',name:f.name||'Imported connection',place:pick.point,geometry:f.geometry,source:'user'};
3333
+ if(pick.anchorId)joint.main=pick.anchorId;
3334
+ edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(f.geometry.map((gg,i)=>id+':'+(gg.id||'m'+i)));});
3335
+ toast('“'+f.name+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — click to place another, or Esc');return;
3336
+ }
3337
+ toast('That favourite has no geometry to place');return;
3338
+ }
3244
3339
  if(pending&&pending.kind==='connection'&&pending.connection){
3245
3340
  const conn=pending.connection;const rc=conn.recipe;
3246
3341
  // Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
@@ -3444,6 +3539,7 @@ function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='o
3444
3539
  const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
3445
3540
  for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
3446
3541
  if(tab==='views')renderViewsTab(); // (re)render the Views list when it becomes visible
3542
+ if(tab==='fav')renderFavTab(); // (re)render the Favourites list when it becomes visible
3447
3543
  updateViewsBtn();
3448
3544
  }
3449
3545
  // The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
@@ -3653,6 +3749,199 @@ function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
3653
3749
  btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
3654
3750
  t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
3655
3751
  }
3752
+ // ════════════════════════════════════════════════════════════════════════════════════════════════════════════
3753
+ // Favourite Connections (Slice F1) — a GLOBAL, cross-project library of saved connection recipes.
3754
+ // The ★ in the whole-connection Inspector saves here (#favConnModal → POST /api/steel-favourites); this tab
3755
+ // browses/searches/filters them, and applying one is arm→click→bake: click a favourite row → arm the placement
3756
+ // crosshair (sticky, so it repeats) → click a target → bake a JointRecipe into C.joints[] + re-detail. A
3757
+ // favourite is {kind:'base-plate'|'shear-plate', params} (parametric, re-fits any member) or {kind:'custom',
3758
+ // geometry} (imported mesh, drops in place). Mirrors the Views tab; no create button (creation is via ★).
3759
+ // ════════════════════════════════════════════════════════════════════════════════════════════════════════════
3760
+ let steelFavourites=[]; // loaded from /api/steel-favourites on 3D enter (global store → cross-project)
3761
+ let favQuery=''; // transient Favourites search filter — NOT persisted
3762
+ let favSearchOpen=false; // is the search input revealed?
3763
+ let favKindFilter=(()=>{try{const k=localStorage.getItem('floless.favKind');return (k==='portable'||k==='geometry')?k:'all';}catch{return 'all';}})();
3764
+ let armedFavId=null; // the favourite whose placement crosshair is armed (persistent .armed row) — transient
3765
+ const FAV_KIND_LABEL={'base-plate':'Base plate','shear-plate':'Shear plate','custom':'Custom (imported)'};
3766
+ const FAV_KIND_COLOR={'base-plate':'#6b7a8d','shear-plate':'#7d8ba0','custom':'#8a97a8'}; // mirror steel-joints.ts GROUPS
3767
+ const FAV_CAT_SUGGEST={'base-plate':['Base plates','Columns'],'shear-plate':['Shear plates','Beams'],'custom':['Imported','Details']};
3768
+ function favIsPortable(f){return !!f&&(f.kind==='base-plate'||f.kind==='shear-plate');}
3769
+ // The saved-recipe payload captured from a selected whole connection: recipe scalars, or an opaque custom mesh.
3770
+ function favRecipeOf(j){if(!j)return null;
3771
+ if(j.kind==='custom')return {kind:'custom',geometry:Array.isArray(j.geometry)?j.geometry:[],name:j.name||'Imported connection'};
3772
+ if(j.kind==='base-plate'||j.kind==='shear-plate')return {kind:j.kind,params:Object.assign({},j.params||{})};
3773
+ return null;}
3774
+ // A key-order-STABLE JSON of a params object, so the ★ Saved/Save match survives a disk round-trip (a reloaded
3775
+ // favourite's params can serialize its keys in a different order than the live joint's — which would flip the ★
3776
+ // back to "Save" and let a duplicate be saved). Recurses into nested objects; arrays keep their (significant) order.
3777
+ function stableStr(o){if(o&&typeof o==='object'&&!Array.isArray(o))return '{'+Object.keys(o).sort().map(k=>JSON.stringify(k)+':'+stableStr(o[k])).join(',')+'}';return JSON.stringify(o);}
3778
+ // A parametric favourite already saved with identical kind + params → the ★ opens it in edit mode.
3779
+ function favMatch(j){const r=favRecipeOf(j);if(!r||r.kind==='custom')return null;const key=stableStr(r.params||{});
3780
+ return steelFavourites.find(f=>f.kind===r.kind&&stableStr(f.params||{})===key)||null;}
3781
+ async function loadSteelFavourites(){try{const res=await fetch('/api/steel-favourites');const d=await res.json();steelFavourites=(d&&d.ok&&Array.isArray(d.favourites))?d.favourites:[];}catch(_){steelFavourites=[];}
3782
+ if(legendTab==='fav')renderFavTab();}
3783
+
3784
+ // ── The ★ Save/edit modal (#favConnModal) ──────────────────────────────────────────────────────────────────
3785
+ let favModalJoint=null; // the selected joint being saved (create mode)
3786
+ let favModalEditId=null; // an existing favourite id (edit mode — rename/recategorize only; recipe is immutable)
3787
+ function favSuggestName(j){if(!j)return '';
3788
+ if(j.kind==='custom')return j.name||'Imported connection';
3789
+ const prof=((P.members||[]).find(m=>m&&m.id===j.main)||{}).profile||'';
3790
+ return (j.kind==='base-plate'?'Base plate':'Shear plate')+(prof?' — '+prof:'');}
3791
+ function favFillModal({title,kind,name,category,portable,hint}){
3792
+ document.getElementById('favModalTitle').textContent=title;
3793
+ const kf=document.getElementById('favKind');kf.className='chip';kf.textContent=FAV_KIND_LABEL[kind]||kind;
3794
+ kf.style.borderColor=portable?'var(--brand)':'var(--line)';kf.style.color=portable?'#bfdbfe':'var(--mut)';
3795
+ document.getElementById('favName').value=name||'';
3796
+ document.getElementById('favCat').value=category||'';
3797
+ const dl=document.getElementById('favCatSuggest');dl.replaceChildren();for(const s of (FAV_CAT_SUGGEST[kind]||[]))dl.appendChild(Object.assign(document.createElement('option'),{value:s}));
3798
+ document.getElementById('favHint').textContent=hint;
3799
+ document.getElementById('favSave').textContent=favModalEditId?'Save changes':'Save to Favourites';
3800
+ document.getElementById('favConnModal').style.display='flex';
3801
+ const ni=document.getElementById('favName');ni.focus();ni.select();}
3802
+ // From the Inspector ★ — create (or, if this exact recipe is already saved, edit its name/category).
3803
+ function openFavModalFor(j){if(!j){toast('Select a connection first');return;}
3804
+ const existing=favMatch(j);favModalJoint=j;favModalEditId=existing?existing.id:null;
3805
+ const portable=j.kind!=='custom';
3806
+ const hint=j.kind==='custom'?'Imported geometry — drops in place at the clicked point; won’t parametrically re-fit.'
3807
+ :'Parametric — re-fits any column or beam you apply it to.';
3808
+ favFillModal({title:existing?'Edit favourite':'Save to Favourites',kind:j.kind,name:existing?existing.name:favSuggestName(j),category:existing?existing.category:'',portable,hint});}
3809
+ // From a Favourites row ⋯ → rename/recategorize an existing favourite (edit mode; no joint needed).
3810
+ function openFavModalEdit(f){if(!f)return;favModalJoint=null;favModalEditId=f.id;
3811
+ favFillModal({title:'Edit favourite',kind:f.kind,name:f.name,category:f.category,portable:favIsPortable(f),
3812
+ hint:f.kind==='custom'?'Imported geometry — drops in place; won’t parametrically re-fit.':'Parametric — re-fits any column or beam.'});}
3813
+ function favModalClose(){document.getElementById('favConnModal').style.display='none';favModalJoint=null;favModalEditId=null;}
3814
+ function favModalIsOpen(){const m=document.getElementById('favConnModal');return !!m&&m.style.display==='flex';}
3815
+ async function favModalSave(){const name=(document.getElementById('favName').value||'').trim();
3816
+ const category=(document.getElementById('favCat').value||'').trim();
3817
+ if(!name){toast('Give the favourite a name');document.getElementById('favName').focus();return;}
3818
+ try{
3819
+ if(favModalEditId){
3820
+ const res=await fetch('/api/steel-favourites/'+encodeURIComponent(favModalEditId),{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({name,category})});
3821
+ if(!res.ok)throw 0;const d=await res.json();const i=steelFavourites.findIndex(f=>f.id===favModalEditId);if(i>=0&&d.favourite)steelFavourites[i]=d.favourite;
3822
+ toast('Favourite updated');
3823
+ }else{
3824
+ const r=favRecipeOf(favModalJoint);if(!r){toast('That connection can’t be saved');return;}
3825
+ const body=r.kind==='custom'?{name,category,kind:'custom',geometry:r.geometry,schemaVersion:1,emittedBy:'floless-steel-editor'}
3826
+ :{name,category,kind:r.kind,params:r.params,schemaVersion:1,emittedBy:'floless-steel-editor'};
3827
+ const res=await fetch('/api/steel-favourites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
3828
+ if(!res.ok)throw 0;const d=await res.json();if(d.favourite)steelFavourites.push(d.favourite);
3829
+ toast('Saved to Favourites ★');
3830
+ }
3831
+ }catch(_){toast('Could not save the favourite');return;}
3832
+ favModalClose();if(legendTab==='fav')renderFavTab();panel();} // panel() re-renders so the Inspector ★ reflects the saved state
3833
+
3834
+ // ── Render the Favourites tab (mirror renderViewsTab) ──────────────────────────────────────────────────────
3835
+ function favByKind(){return steelFavourites.filter(f=>{
3836
+ if(favKindFilter==='portable'&&!favIsPortable(f))return false;
3837
+ if(favKindFilter==='geometry'&&f.kind!=='custom')return false;
3838
+ return true;});}
3839
+ function toggleFavSearch(){favSearchOpen=!favSearchOpen;if(!favSearchOpen)favQuery='';renderFavTab();
3840
+ if(favSearchOpen){const inp=document.getElementById('favSearchInput');if(inp)inp.focus();}}
3841
+ function setFavKindFilter(k){favKindFilter=k;try{localStorage.setItem('floless.favKind',k);}catch{}renderFavTab();}
3842
+ function renderFavTab(){const host=document.getElementById('m3dFavBody');if(!host)return;host.replaceChildren();
3843
+ // Header: a kind filter (All / Portable / Geometry) + a 🔍 search toggle. No create button — ★ lives in the Inspector.
3844
+ const head=document.createElement('div');head.id='favHeadRow';
3845
+ const seg=document.createElement('div');seg.id='favKindFilter';seg.className='seg-group';seg.setAttribute('role','group');seg.setAttribute('aria-label','Filter favourites by kind');
3846
+ for(const [k,label,tip] of [['all','All','Show all favourites'],['portable','Portable','Parametric base / shear plates that re-fit any member'],['geometry','Geometry','Imported geometry that drops in place']]){
3847
+ const b=document.createElement('button');b.type='button';b.textContent=label;b.dataset.tip=tip;if(favKindFilter===k)b.classList.add('on');b.setAttribute('aria-pressed',String(favKindFilter===k));b.addEventListener('click',()=>setFavKindFilter(k));seg.appendChild(b);}
3848
+ const stog=document.createElement('button');stog.type='button';stog.id='favSearchTog';stog.setAttribute('aria-label','Search favourites');stog.dataset.tip='Search favourites by name';if(favSearchOpen)stog.classList.add('on');stog.appendChild(magnifierSvg());stog.addEventListener('click',toggleFavSearch);
3849
+ head.append(seg,stog);host.appendChild(head);
3850
+ // Search box (revealed on demand)
3851
+ if(favSearchOpen){const sb=document.createElement('div');sb.id='favSearch';sb.className='show'+(favQuery?' has':'');
3852
+ const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');ico.appendChild(magnifierSvg());
3853
+ const inp=document.createElement('input');inp.id='favSearchInput';inp.type='text';inp.placeholder='Search favourites…';inp.autocomplete='off';inp.value=favQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search favourites');
3854
+ const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
3855
+ inp.addEventListener('input',()=>{favQuery=inp.value;applyFavFilter();});
3856
+ inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';favQuery='';applyFavFilter();}else{toggleFavSearch();}}});
3857
+ clr.addEventListener('click',()=>{if(!inp.value&&!favQuery)return;inp.value='';favQuery='';applyFavFilter();inp.focus();});
3858
+ sb.append(ico,inp,clr);host.appendChild(sb);}
3859
+ // Body: empty (nothing saved), empty-in-filter, or the list
3860
+ if(!steelFavourites.length){const e=Object.assign(document.createElement('div'),{className:'favempty'});e.textContent='Select a base plate, shear plate, or imported connection in the model and click ★ Save to build your library.';host.appendChild(e);return;}
3861
+ const rows=favByKind();
3862
+ if(!rows.length){const e=document.createElement('div');e.className='favnores';e.append(document.createTextNode('No favourites in this filter.'));
3863
+ const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Show all';clr.addEventListener('click',()=>setFavKindFilter('all'));e.appendChild(clr);host.appendChild(e);return;}
3864
+ const list=document.createElement('div');list.id='favList';host.appendChild(list);
3865
+ for(const f of rows)list.appendChild(buildFavRow(f));
3866
+ applyFavFilter();
3867
+ }
3868
+ function buildFavRow(f){const row=document.createElement('div');row.className='favrow'+(armedFavId===f.id?' armed':'');row.dataset.id=f.id;row.tabIndex=0;
3869
+ const sw=Object.assign(document.createElement('span'),{className:'favsw'});sw.style.setProperty('--sw',FAV_KIND_COLOR[f.kind]||'#8a97a8');sw.dataset.tip=FAV_KIND_LABEL[f.kind]||f.kind;
3870
+ const name=Object.assign(document.createElement('span'),{className:'favname',textContent:f.name});name.dataset.tip=(f.name||'')+' — click to place in the model';
3871
+ const badge=Object.assign(document.createElement('span'),{className:'favbadge'+(favIsPortable(f)?' portable':''),textContent:favIsPortable(f)?'Portable':'Geometry only'});badge.dataset.tip=favIsPortable(f)?'Parametric — re-fits any member':'Imported geometry — drops in place';
3872
+ const dots=Object.assign(document.createElement('span'),{className:'vdots',textContent:'⋯'});dots.dataset.tip='More — Rename / recategorize';dots.setAttribute('role','button');dots.addEventListener('click',e=>{e.stopPropagation();openFavRowMenu(f,dots);});
3873
+ const x=Object.assign(document.createElement('span'),{className:'vx',textContent:'×'});x.dataset.tip='Remove from Favourites';x.addEventListener('click',e=>{e.stopPropagation();deleteFav(f);});
3874
+ row.append(sw,name,badge,dots,x);
3875
+ row.addEventListener('click',e=>{if(e.target===dots||e.target===x)return;armFavouriteInsert(f);});
3876
+ row.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();armFavouriteInsert(f);}});
3877
+ return row;}
3878
+ // Filter rows by name (kind is already applied at render); no-results shows a muted line + Clear. Mirrors applyViewsFilter.
3879
+ function applyFavFilter(){const host=document.getElementById('m3dFavBody');if(!host)return;const list=document.getElementById('favList');
3880
+ const old=host.querySelector('.favnores');if(old&&list)old.remove(); // keep the empty-in-filter notice (no list); only drop the search no-results
3881
+ const sb=document.getElementById('favSearch');if(sb)sb.classList.toggle('has',!!favQuery);
3882
+ if(!list)return;const q=(favQuery||'').trim().toLowerCase();const rows=[...list.querySelectorAll('.favrow')];
3883
+ if(!q){rows.forEach(r=>r.style.display='');return;}
3884
+ let any=false;rows.forEach(r=>{const nm=(r.querySelector('.favname')||{}).textContent||'';const hit=nm.toLowerCase().includes(q);r.style.display=hit?'':'none';if(hit)any=true;});
3885
+ if(!any){const e=document.createElement('div');e.className='favnores';e.append(document.createTextNode('No favourites match “'+favQuery.trim()+'”.'));
3886
+ const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Clear';clr.addEventListener('click',()=>{favQuery='';const inp=document.getElementById('favSearchInput');if(inp)inp.value='';applyFavFilter();if(inp)inp.focus();});
3887
+ e.appendChild(clr);host.appendChild(e);}
3888
+ }
3889
+ // The per-row ⋯ popup — reuses the .m3dmenu skin. F1: Place + Rename/recategorize (the F2 terminal relay lands later).
3890
+ function openFavRowMenu(f,anchorEl){closeFavRowMenu();
3891
+ const m=document.createElement('div');m.id='favRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
3892
+ const mk=(label,fn)=>{const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeFavRowMenu();fn();});return b;};
3893
+ m.append(mk('Place in model',()=>armFavouriteInsert(f)),mk('Rename / recategorize…',()=>openFavModalEdit(f)));
3894
+ document.body.appendChild(m);
3895
+ const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||184,mh=m.offsetHeight||80;
3896
+ let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
3897
+ m.style.left=x+'px';m.style.top=y+'px';
3898
+ setTimeout(()=>document.addEventListener('mousedown',favRowMenuOutside,true),0);
3899
+ }
3900
+ function favRowMenuOutside(e){const m=document.getElementById('favRowMenu');if(m&&!m.contains(e.target))closeFavRowMenu();}
3901
+ function closeFavRowMenu(){const m=document.getElementById('favRowMenu');if(m)m.remove();document.removeEventListener('mousedown',favRowMenuOutside,true);}
3902
+ // Delete — no confirm (a recipe, never live geometry), with an undo toast that re-POSTs the favourite.
3903
+ async function deleteFav(f){const idx=steelFavourites.findIndex(x=>x.id===f.id);if(idx<0)return;
3904
+ steelFavourites.splice(idx,1);
3905
+ if(armedFavId===f.id&&window.Steel3DView&&window.Steel3DView.setInsertMode)window.Steel3DView.setInsertMode(false); // disarm if the deleted one was armed
3906
+ renderFavTab();
3907
+ // Verify the server actually removed it before promising an undo: a swallowed non-OK (500 / a read-only or full
3908
+ // ~/.floless) would leave the row gone in the UI but still on disk — a durable, cross-project ghost that reappears
3909
+ // next load. 404 = already gone = the desired end state.
3910
+ let ok=false;
3911
+ try{const res=await fetch('/api/steel-favourites/'+encodeURIComponent(f.id),{method:'DELETE'});ok=res.ok||res.status===404;}catch(_){ok=false;}
3912
+ if(!ok){steelFavourites.splice(Math.min(idx,steelFavourites.length),0,f);renderFavTab();toast('Could not remove “'+f.name+'” — it’s still saved. Try again.');return;} // don't lie to the user; skip the undo toast (nothing was removed)
3913
+ // Undo re-POSTs, which mints a NEW id (the restored favourite is value-identical but not id-identical) — F2's
3914
+ // terminal relay must therefore not assume a favourite id survives an undo.
3915
+ undoToast('Removed “'+f.name+'”',async()=>{try{
3916
+ const body=f.kind==='custom'?{name:f.name,category:f.category,kind:'custom',geometry:f.geometry,schemaVersion:f.schemaVersion||1,emittedBy:f.emittedBy}
3917
+ :{name:f.name,category:f.category,kind:f.kind,params:f.params,schemaVersion:f.schemaVersion||1,emittedBy:f.emittedBy};
3918
+ const res=await fetch('/api/steel-favourites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
3919
+ if(res.ok){const d=await res.json();if(d.favourite){steelFavourites.push(d.favourite);renderFavTab();}}
3920
+ }catch(_){toast('Could not restore the favourite');}});
3921
+ }
3922
+
3923
+ // ── Apply a favourite in-editor: arm the placement crosshair (sticky → repeat), then click a target ──────────
3924
+ function armFavouriteInsert(f){if(!f)return;
3925
+ if(!view3d){toast('Switch to the 3D view to place a connection');return;}
3926
+ if(f.kind==='custom'&&(!Array.isArray(f.geometry)||!f.geometry.length)){toast('That favourite has no geometry to place');return;}
3927
+ armedFavId=f.id;window.Steel3DView.setInsertMode(true,{kind:'favourite',favourite:f,sticky:true});
3928
+ const target=f.kind==='base-plate'?'a column':f.kind==='shear-plate'?'a beam end':'the model';
3929
+ const note=f.kind==='custom'?' (imported geometry — drops in place, won’t re-fit)':'';
3930
+ showFavArmBar('Placing “'+f.name+'” — click '+target+note+'. Esc when done.');
3931
+ if(legendTab==='fav')renderFavTab(); // mark the armed row
3932
+ }
3933
+ // Called by view3dApi.onInsertModeChange(false) — the crosshair disarmed (Esc / toolbar cancel / the bar's Cancel).
3934
+ function favDisarmed(){if(armedFavId==null&&!favArmBarOn())return;armedFavId=null;hideFavArmBar();if(legendTab==='fav')renderFavTab();}
3935
+ // A PERSISTENT armed-placement bar, distinct from the transient toast(): it stays up across repeat placements and
3936
+ // is dismissed only on disarm. Built on demand like undoToast; its Cancel mirrors Esc.
3937
+ function showFavArmBar(msg){let b=document.getElementById('favArmBar');
3938
+ if(!b){b=document.createElement('div');b.id='favArmBar';b.setAttribute('role','status');b.setAttribute('aria-live','polite');b.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:none;align-items:center;gap:12px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;max-width:min(560px,92vw)';document.body.appendChild(b);}
3939
+ b.replaceChildren();b.appendChild(document.createTextNode(msg));
3940
+ const cancel=document.createElement('button');cancel.type='button';cancel.textContent='Cancel';cancel.dataset.tip='Stop placing (Esc)';cancel.style.cssText='background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:4px;padding:2px 8px;font:12px system-ui;cursor:pointer;box-shadow:none;flex:none';
3941
+ cancel.addEventListener('click',()=>{if(window.Steel3DView)window.Steel3DView.setInsertMode(false);}); // disarm → onInsertModeChange → favDisarmed hides this bar
3942
+ b.appendChild(cancel);b.style.display='flex';}
3943
+ function hideFavArmBar(){const b=document.getElementById('favArmBar');if(b)b.style.display='none';}
3944
+ function favArmBarOn(){const b=document.getElementById('favArmBar');return !!b&&b.style.display!=='none';}
3656
3945
  // Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
3657
3946
  // — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
3658
3947
  // base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
@@ -4131,7 +4420,7 @@ async function setView(on){
4131
4420
  window.Steel3DView.show();
4132
4421
  await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
4133
4422
  window.Steel3DView.setSelection(selIds);
4134
- wire3DBar();wireLegendTabs();build3DLegend();showLegendPanel(); // build the Objects body, then open the panel on the last-used tab
4423
+ wire3DBar();wireLegendTabs();loadSteelFavourites();build3DLegend();showLegendPanel(); // build the Objects body + load the (global) favourites library, then open the panel on the last-used tab
4135
4424
  reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
4136
4425
  }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
4137
4426
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
@@ -4880,6 +5169,12 @@ document.getElementById('askAiClose').onclick = askAiClose;
4880
5169
  document.getElementById('askAiCancel').onclick = askAiClose;
4881
5170
  document.getElementById('askAiBackdrop').onclick = askAiClose;
4882
5171
  document.getElementById('askAiText').oninput = askAiSyncSend;
5172
+ // Favourites modal (#favConnModal) wiring — a local sibling of the Ask-AI modal.
5173
+ document.getElementById('favClose').onclick = favModalClose;
5174
+ document.getElementById('favCancel').onclick = favModalClose;
5175
+ document.getElementById('favBackdrop').onclick = favModalClose;
5176
+ document.getElementById('favSave').onclick = favModalSave;
5177
+ document.getElementById('favName').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); favModalSave(); } });
4883
5178
  // Drop zone click → file picker
4884
5179
  document.getElementById('askAiDrop').onclick = () => document.getElementById('askAiFile').click();
4885
5180
  // File input (supports multiple)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {