@floless/app 0.81.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.81.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.81.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}
@@ -125,12 +125,13 @@
125
125
  #moreMenu #m3dInsert.on{color:var(--brand)}
126
126
  #moreMenu #m3dInsertMenu{left:auto;right:calc(100% + 4px);top:0}
127
127
  body:not(.v3d) #moreMenu #insWrap{display:none} /* Insert detail places into the 3D scene — hide it in 2D (needs 2 ids to beat the .m3dwrap.ins-in-menu display:block) */
128
- #moreMenu button.msnap{display:flex;align-items:center;gap:0}
129
- #moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
128
+ #moreMenu button.msnap,#moreMenu button.dtog{display:flex;align-items:center;gap:0} /* .dtog = the Display show/hide toggles, same slider switch as Snapping (.msnap) */
129
+ #moreMenu button.msnap.on,#moreMenu button.dtog.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
130
130
  #moreMenu .mck,.cmmenu .mck,.m3dmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows, the Move/Copy → Drag-to-move/copy toggle, and the Work-area toggles */
131
131
  #moreMenu .mck::after,.cmmenu .mck::after,.m3dmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
132
- #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
133
- #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
132
+ #moreMenu button.msnap.on .mck,#moreMenu button.dtog.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
133
+ #moreMenu button.msnap.on .mck::after,#moreMenu button.dtog.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
134
+ #moreMenu button.dtog:disabled{opacity:.45;cursor:default} /* e.g. the grid toggle when the model has no grid */
134
135
  .m3dmenu button.wtog{display:flex;align-items:center;justify-content:flex-start;gap:0}
135
136
  .m3dmenu button.wtog.on{color:var(--text)} /* the slider carries the on-state — don't also brand the label text */
136
137
  #moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
@@ -418,8 +419,47 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
418
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) */
419
420
  #m3dLegendBody{padding:8px 10px}
420
421
  #m3dViewsBody,#m3dFavBody{padding:8px 10px}
421
- /* Favourites placeholder (a later slice wires its content). */
422
- #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}
423
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 */
424
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 */
425
465
  #m3dLegend .lrow:hover{background:#33415580}
@@ -591,10 +631,10 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
591
631
  </div>
592
632
  <button class=msec-hdr data-sec=display aria-expanded=false data-tip="Show/hide plan layers, and edit grid lines">Display<span class=chev aria-hidden=true>▸</span></button>
593
633
  <div class=msec-body>
594
- <button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">Hide dimensions</button>
595
- <button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
596
- <button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">Hide grid</button>
597
- <button id=lenToggleB data-tip="Show each selected member's length on the canvas">Show member length</button>
634
+ <button id=dimToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide all placed dimensions on the plan"><span class=mck aria-hidden=true></span>Dimensions</button>
635
+ <button id=calloutToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan"><span class=mck aria-hidden=true></span>Callouts</button>
636
+ <button id=gridToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show or hide the grid lines in 2D and 3D"><span class=mck aria-hidden=true></span>Grid lines</button>
637
+ <button id=lenToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show each selected member's length on the canvas"><span class=mck aria-hidden=true></span>Member length</button>
598
638
  <button id=gridEditB data-tip="Grid lines — a plan reference with structural bay spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
599
639
  </div>
600
640
  <button class=msec-hdr data-sec=detailing aria-expanded=false data-tip="Connection details, plates, frames, and inserted detail images">Detailing<span class=chev aria-hidden=true>▸</span></button>
@@ -715,7 +755,7 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
715
755
  </div>
716
756
  <div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
717
757
  <div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
718
- <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>
719
759
  </div>
720
760
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
721
761
  <div id=m3dAxes></div>
@@ -810,6 +850,24 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
810
850
  </div>
811
851
  </div>
812
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>
813
871
  <div id=lightbox><div class=mbackdrop id=lbBackdrop></div>
814
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>
815
873
  <script>
@@ -1329,7 +1387,7 @@ function gridDefaultOrigin(){const xs=[],ys=[];for(const m of ((P&&P.members)||[
1329
1387
  if(xs.length)return [Math.min(...xs),Math.max(...ys)]; // bottom-left of the steel
1330
1388
  return [(typeof X0==='number'?X0:0)+50,(typeof Y1==='number'?Y1:300)-50];} // empty plan → near the sheet corner
1331
1389
  function refresh3DGrid(){if(view3dReady&&window.Steel3DView&&window.Steel3DView.refreshGrid)window.Steel3DView.refreshGrid();}
1332
- function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.textContent=gridOn()?'Hide grid':'Show grid';}
1390
+ function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.classList.toggle('on',gridOn());b.setAttribute('aria-checked',String(gridOn()));}
1333
1391
  // ONE visibility switch, THREE surfaces: the grid panel's checkbox, the 3D legend's "Grid lines" row,
1334
1392
  // and the ⋯ menu item — all call this. grid.on is contract data, so the flip is undoable like every
1335
1393
  // other grid operation.
@@ -1593,7 +1651,7 @@ function render(){
1593
1651
  s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
1594
1652
  s+=renderSelLenLabels(); // on-select member-length chips (2D), gated by the ⋯ Display "Show member length" toggle
1595
1653
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
1596
- svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle(); updLenToggle();
1654
+ svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle(); updLenToggle(); updDimToggle(); updCalloutToggle();
1597
1655
  if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);refreshLegendSel();} // keep the 3D highlight + legend selection in sync; selecting a member clears any clip selection (exclusive)
1598
1656
  try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1599
1657
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
@@ -1821,13 +1879,15 @@ function panel(){
1821
1879
  <div class="row f" style="gap:6px;flex-wrap:wrap">
1822
1880
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
1823
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>
1824
1883
  </div>`;
1825
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();};}
1826
1885
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1827
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);}
1828
1888
  return;
1829
1889
  }
1830
- const isBP=j.kind==='base-plate',pp=j.params||{};
1890
+ const isBP=j.kind==='base-plate',pp=j.params||{};const savedFav=favMatch(j);
1831
1891
  const plate=(partsById||{})[cs.conn+':plate']||null;
1832
1892
  const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
1833
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>`;
@@ -1847,6 +1907,7 @@ function panel(){
1847
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>`:''}
1848
1908
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1849
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>
1850
1911
  </div>`;
1851
1912
  const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1852
1913
  {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
@@ -1854,6 +1915,7 @@ function panel(){
1854
1915
  {const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
1855
1916
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1856
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);}
1857
1919
  return;
1858
1920
  }}
1859
1921
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
@@ -3011,14 +3073,14 @@ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMod
3011
3073
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
3012
3074
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
3013
3075
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
3014
- function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
3015
- document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
3016
- function updLenToggle(){const b=document.getElementById('lenToggleB');if(b)b.textContent=showSelLen?'Hide member length':'Show member length';}
3017
- document.getElementById('lenToggleB').onclick=()=>{showSelLen=!showSelLen;try{localStorage.setItem('steel:selLen:v1',showSelLen?'1':'0');}catch(_){}updLenToggle();render();}; // render() repaints the 2D chips + pushes labels to 3D
3018
- function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
3019
- document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
3076
+ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b){b.classList.toggle('on',dimsVisible);b.setAttribute('aria-checked',String(dimsVisible));}}
3077
+ document.getElementById('dimToggleB').onclick=e=>{e.stopPropagation();dimsVisible=!dimsVisible;updDimToggle();render();};
3078
+ function updLenToggle(){const b=document.getElementById('lenToggleB');if(b){b.classList.toggle('on',showSelLen);b.setAttribute('aria-checked',String(showSelLen));}}
3079
+ document.getElementById('lenToggleB').onclick=e=>{e.stopPropagation();showSelLen=!showSelLen;try{localStorage.setItem('steel:selLen:v1',showSelLen?'1':'0');}catch(_){}updLenToggle();render();}; // render() repaints the 2D chips + pushes labels to 3D
3080
+ function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b){b.classList.toggle('on',calloutsVisible);b.setAttribute('aria-checked',String(calloutsVisible));}}
3081
+ document.getElementById('calloutToggleB').onclick=e=>{e.stopPropagation();calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
3020
3082
  document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
3021
- document.getElementById('gridToggleB').onclick=()=>gridSetVisible(!gridOn());
3083
+ document.getElementById('gridToggleB').onclick=e=>{e.stopPropagation();gridSetVisible(!gridOn());};
3022
3084
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
3023
3085
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
3024
3086
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
@@ -3043,6 +3105,7 @@ addEventListener('keydown',e=>{
3043
3105
  if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
3044
3106
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
3045
3107
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
3108
+ if(e.key==='Escape'&&favModalIsOpen()){favModalClose();return;}
3046
3109
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
3047
3110
  if(e.key==='Escape'&&connLibOpen()){closeConnLib();return;}
3048
3111
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
@@ -3105,7 +3168,7 @@ function moreOpen(){return moreMenu.classList.contains('open');}
3105
3168
  function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
3106
3169
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
3107
3170
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
3108
- moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
3171
+ moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.dtog')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
3109
3172
  // "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
3110
3173
  // Every ⋯ section is a collapse/expand accordion (Snapping + Display + Detailing + …); state persists in localStorage.
3111
3174
  {const SEC_KEY='steelMoreSections';let openSecs;try{openSecs=new Set(JSON.parse(localStorage.getItem(SEC_KEY)||'[]'));}catch(e){openSecs=new Set();}
@@ -3221,7 +3284,7 @@ const view3dApi={
3221
3284
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
3222
3285
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
3223
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)
3224
- 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)
3225
3288
  onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
3226
3289
  onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
3227
3290
  const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
@@ -3240,6 +3303,39 @@ const view3dApi={
3240
3303
  toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
3241
3304
  },
3242
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
+ }
3243
3339
  if(pending&&pending.kind==='connection'&&pending.connection){
3244
3340
  const conn=pending.connection;const rc=conn.recipe;
3245
3341
  // Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
@@ -3443,6 +3539,7 @@ function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='o
3443
3539
  const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
3444
3540
  for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
3445
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
3446
3543
  updateViewsBtn();
3447
3544
  }
3448
3545
  // The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
@@ -3652,6 +3749,199 @@ function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
3652
3749
  btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
3653
3750
  t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
3654
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';}
3655
3945
  // Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
3656
3946
  // — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
3657
3947
  // base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
@@ -4130,7 +4420,7 @@ async function setView(on){
4130
4420
  window.Steel3DView.show();
4131
4421
  await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
4132
4422
  window.Steel3DView.setSelection(selIds);
4133
- 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
4134
4424
  reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
4135
4425
  }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
4136
4426
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
@@ -4879,6 +5169,12 @@ document.getElementById('askAiClose').onclick = askAiClose;
4879
5169
  document.getElementById('askAiCancel').onclick = askAiClose;
4880
5170
  document.getElementById('askAiBackdrop').onclick = askAiClose;
4881
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(); } });
4882
5178
  // Drop zone click → file picker
4883
5179
  document.getElementById('askAiDrop').onclick = () => document.getElementById('askAiFile').click();
4884
5180
  // File input (supports multiple)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.81.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": {