@floless/app 0.48.1 → 0.50.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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.48.1" : void 0,
52859
+ define: true ? "0.50.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.48.1" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.50.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // workflow-update.ts
@@ -212,6 +212,7 @@
212
212
  "segments": { "type": "array", "items": { "$ref": "#/$defs/segment" } },
213
213
  "labels": { "type": "array", "items": { "$ref": "#/$defs/label" } },
214
214
  "details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
215
+ "callouts": { "type": "array", "items": { "$ref": "#/$defs/callout" }, "description": "Drawing cross-references drawn ON this plan that point to another sheet (section cuts, elevation marks, detail refs). The editor renders a clickable bubble at each `disp` that jumps to the referenced sheet." },
215
216
  "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
216
217
  "members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
217
218
  "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
@@ -281,6 +282,26 @@
281
282
  "disp": { "$ref": "#/$defs/point2" }
282
283
  }
283
284
  },
285
+ "callout": {
286
+ "type": "object",
287
+ "required": ["kind", "target"],
288
+ "additionalProperties": true,
289
+ "description": "A drawing cross-reference drawn ON this plan that points to another sheet — a section cut, an elevation mark, or a detail reference. Clicking its bubble in the editor jumps to the referenced sheet (a loaded plan → switch to it & frame it; otherwise the detail lightbox).",
290
+ "properties": {
291
+ "kind": { "enum": ["section", "elevation", "detail-ref"], "description": "section = a section cut symbol; elevation = an elevation / interior-elevation mark; detail-ref = a detail callout pointing to another sheet." },
292
+ "text": { "type": "string", "description": "The mark as drawn, e.g. \"A\", \"A-A\", \"3/S-501\"." },
293
+ "disp": { "$ref": "#/$defs/point2", "description": "Where the mark is drawn on THIS plan (display space) — the clickable hotspot anchor." },
294
+ "target": {
295
+ "type": "object",
296
+ "additionalProperties": true,
297
+ "description": "Where the mark points.",
298
+ "properties": {
299
+ "sheet": { "type": "string", "description": "Referenced sheet, e.g. S-301." },
300
+ "number": { "type": "string", "description": "Detail / section number on that sheet, e.g. \"A\" or \"3\"." }
301
+ }
302
+ }
303
+ }
304
+ },
284
305
  "tos_callout": {
285
306
  "type": "object",
286
307
  "required": ["elev_in"],
@@ -223,6 +223,9 @@ Important optional fields:
223
223
  "weights": { "W16X26": 26, "HSS6X6X3/8": 27.48 }, // profile → plf; null for unresolved
224
224
  "moment_frames": [ … ], // from Frame Elevation sheets
225
225
  "detail_bubbles": { "S-504": { "5": [0.32, 0.71] } }, // sheet → { number → [fx,fy] }
226
+ "connections": [ // vendor-neutral connection library
227
+ { "id": "cx-m1", "type": "moment", "detail": "5-S504", "targets": { "tekla": "146" }, "source": "drawing" }
228
+ ],
226
229
  "plans": [
227
230
  {
228
231
  "sheet": "S-202",
@@ -234,6 +237,7 @@ Important optional fields:
234
237
  "segments": [ { "id": "seg-1", "a": [x,y], "b": [x,y], "o": "H" } ],
235
238
  "labels": [ { "text": "W16X26", "disp": [x,y] } ],
236
239
  "details": [ { "text": "5-S504", "disp": [x,y] } ],
240
+ "callouts": [ { "kind": "section", "text": "A", "disp": [x,y], "target": { "sheet": "S-301", "number": "A" } } ],
237
241
  "tos_callouts": [ { "elev_in": 198, "type": "TOS", "disp": [x,y] } ],
238
242
  "members": [
239
243
  {
@@ -245,7 +249,7 @@ Important optional fields:
245
249
  "rfi": false,
246
250
  "mf": false,
247
251
  "ends": [
248
- { "tos": 198, "note": "moment", "tosDef": false, "detail": "5-S504" },
252
+ { "tos": 198, "note": "moment", "tosDef": false, "detail": "5-S504", "conn": "cx-m1" },
249
253
  { "tos": 198, "note": "shear", "tosDef": true, "detail": "" }
250
254
  ]
251
255
  },
@@ -282,6 +286,25 @@ Key field rules:
282
286
  moment-frame member (persistent; survives AISC resolution).
283
287
  - `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
284
288
  **machine-local** and **never committed**.
289
+ - **Connection library (`connections[]`) + per-end refs.** A vendor-neutral top-level
290
+ `connections: [{ id, type, detail, targets:{ tekla:"146", … }, source }]` maps a connection
291
+ TYPE → the firm's design detail# → per-platform component ids. A member end references a row by id
292
+ via `ends[].conn` (or `col.conn` for a column); the legacy `note`/`detail` stay as the fallback
293
+ when `conn` is empty. `source` is `drawing` (read from a callout — best evidence), `company-rules`
294
+ (the firm's standard), or `user`. Prefer a `conn` reference over free text when the connection is
295
+ known — the editor's per-end picker and the Connections confidence card both key on it.
296
+ - **Company-rules pre-fill (§5.5).** If a firm-standard knowledge file is present (e.g.
297
+ `company-rules.md`: "moment → 50S504 → Tekla 146"), read it and emit those rows in `connections[]`
298
+ with `source: "company-rules"`, then PUT. The user reviews/edits them in the editor's
299
+ **Connections** library panel and assigns a row per member end. No new agent verb — it's contract data.
300
+ - **Clickable callouts (`callouts[]`).** Harvest the cross-reference marks drawn ON the plan that
301
+ point to ANOTHER sheet — **section cuts** (e.g. a circle with `A` over `A` and a sheet ref),
302
+ **elevation/interior-elevation marks**, and **detail references** — as per-plan
303
+ `callouts: [{ kind:"section"|"elevation"|"detail-ref", text:"A", disp:[x,y], target:{ sheet:"S-301", number:"A" } }]`.
304
+ `disp` is where the mark sits on THIS plan (display space — the clickable hotspot); `target` is where
305
+ it points. The editor draws a clickable bubble at each `disp`: clicking jumps to the referenced sheet
306
+ if it's loaded as a plan, else opens the detail lightbox. (Keep `details[]` as-is — those are the
307
+ detail callouts that already preview; `callouts[]` adds the section/elevation marks and off-sheet refs.)
285
308
 
286
309
  ### 5. Write to the contract store
287
310
 
@@ -68,7 +68,16 @@
68
68
  rect.dethot{cursor:pointer;fill:rgba(168,85,247,.16);stroke:#a855f7;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
69
69
  rect.dethot:hover{fill:rgba(168,85,247,.34)}
70
70
  text.dettx{fill:#ddd6fe;font:bold 11px system-ui;text-anchor:middle;pointer-events:none}
71
- #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
71
+ /* clickable callout bubbles (Workstream B): section/elevation/detail-ref + plain details — navigable cross-references drawn on the plan in select mode. Existing palette only: purple #a855f7 (details), cyan #22d3ee (sections/elevations); solid vs dashed separates the kinds. */
72
+ g.cohot{cursor:pointer}
73
+ circle.cobub{stroke-width:1.5;vector-effect:non-scaling-stroke;opacity:.55}
74
+ circle.cobub.k-detail,circle.cobub.k-detailref{stroke:#a855f7;color:#a855f7;fill:rgba(168,85,247,.12)}
75
+ circle.cobub.k-section,circle.cobub.k-elev{stroke:#22d3ee;color:#22d3ee;fill:rgba(34,211,238,.10)}
76
+ circle.cobub.k-detailref,circle.cobub.k-elev{stroke-dasharray:3 3}
77
+ g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
78
+ text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
79
+ g.cohot:hover text.cotx{opacity:1}
80
+ #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
72
81
  #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
73
82
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
74
83
  .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}
@@ -93,7 +102,11 @@
93
102
  .chip{display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;border:1px solid var(--line);color:var(--mut);margin:0 3px 3px 0}
94
103
  .chip.assumed,.chip.weak{border-color:#713f12;color:#fbbf24} .chip.fail{border-color:#7f1d1d;color:#fca5a5}
95
104
  tr.confrow{cursor:pointer} .conf-expand{color:var(--mut);font-size:12px;line-height:1.6;background:#0b1220}
96
- .ftab td .combo{padding:4px 6px} .ftab .rea{color:var(--mut);font-size:12px} .rfichip{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:#7f1d1d;color:#fecaca;border-radius:9px;font-size:11px;padding:0 5px}
105
+ .ftab td .combo{padding:4px 6px} .ftab .rea{color:var(--mut);font-size:12px}
106
+ .ftab-inp{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:4px;padding:4px 6px;width:100%;font:13px system-ui}
107
+ .ftab-inp:focus{outline:none;border-color:var(--brand)}
108
+ .ftab .cdel{padding:3px 7px;font-size:11px} .ftab .csrc{white-space:nowrap}
109
+ .connset{opacity:.55} .rfichip{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:#7f1d1d;color:#fecaca;border-radius:9px;font-size:11px;padding:0 5px}
97
110
  .mbackdrop{position:absolute;inset:0;background:rgba(2,8,23,.62)}
98
111
  .mpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;width:min(880px,92vw);max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
99
112
  .mhead{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line)}
@@ -211,6 +224,7 @@
211
224
  <div id=moreMenu role=menu>
212
225
  <div class=mlabel>Dimensions</div>
213
226
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
227
+ <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
214
228
  <hr>
215
229
  <div class=mlabel>Coordinate system</div>
216
230
  <button id=csSetB title="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
@@ -223,6 +237,7 @@
223
237
  <hr>
224
238
  <div class=mlabel>Reference</div>
225
239
  <button id=detailsBtn>Details</button>
240
+ <button id=connBtn title="Connection library — map each connection type to its design detail# and per-platform component id; reference a row from each member end">Connections</button>
226
241
  <button id=framesBtn>Frames</button>
227
242
  <hr>
228
243
  <div class=mlabel>Session</div>
@@ -272,6 +287,12 @@
272
287
  <div class=hint id=dnHint>For anything the auto‑detection missed. The image stays on your machine.</div>
273
288
  </div>
274
289
  <div class=mgrid id=detailsGrid></div></div></div>
290
+ <div id=connModal><div class=mbackdrop id=connBackdrop></div>
291
+ <div class=mpanel><div class=mhead><b>Connection library</b><div style="display:flex;gap:8px;align-items:center"><button id=connNew>+ New row</button><button id=connClose>✕</button></div></div>
292
+ <div class=conf-filter><span class=glab>Platform</span><div class=grp id=connPlatGrp><button class=on data-plat=tekla>Tekla</button><button data-plat=sds2>SDS2</button><button data-plat=revit>Revit</button></div><span class=hint style="margin:0">The design detail# is platform-neutral; the component id is per-platform.</span></div>
293
+ <div style="overflow:auto;flex:1;padding:14px;scrollbar-width:thin">
294
+ <table class=ftab id=connTable><thead><tr><th style="width:24%">Type</th><th style="width:24%">Detail #</th><th id=connCompHead style="width:24%">Tekla id</th><th style="width:18%">Source</th><th style="width:10%"></th></tr></thead><tbody id=connBody></tbody></table>
295
+ </div></div></div>
275
296
  <div id=framesModal><div class=mbackdrop id=framesBackdrop></div>
276
297
  <div class=mpanel><div class=mhead><b>Moment frame schedule</b><button id=framesClose>✕</button></div>
277
298
  <div id=framesGrid style="padding:14px;overflow:auto"></div></div></div>
@@ -427,6 +448,7 @@ function main() {
427
448
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
428
449
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
429
450
  let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
451
+ let calloutsVisible=true; // Workstream B: show/hide the clickable callout bubbles (details + section/elevation/detail-ref) on the plan in select mode
430
452
  let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
431
453
  let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
432
454
  let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
@@ -739,6 +761,22 @@ function render(){
739
761
  s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
740
762
  if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
741
763
  s+=`<rect class=dethot data-det="${esc(d.text)}" x="${d.disp[0]-w/2}" y="${d.disp[1]-9}" width="${w}" height="18" rx="3"><title>${esc(d.text)}</title></rect><text class=dettx x="${d.disp[0]}" y="${d.disp[1]+4}">${esc(d.text)}</text>`;}
764
+ // Workstream B: clickable callout bubbles in normal select mode — details[] + per-plan callouts[].
765
+ // data-co-sheet drives the smart jump (loaded plan → switch; else data-co-prev → detail lightbox).
766
+ // hidden while a sub-tool is armed (dim-split / geo extend-trim-split) so the bubbles never distract
767
+ // or shadow a tool click — those handlers run earlier in pointerdown, but don't draw navigables mid-tool.
768
+ if(calloutsVisible&&mode==='sel'&&!picking&&!dimMode&&!csaxisMode&&!dimSplitMode&&!geoMode){
769
+ const sheetLoaded=sh=>!!sh&&C.plans.some(p=>p.sheet&&p.sheet.toUpperCase()===String(sh).toUpperCase());
770
+ // honest destination hint: loaded → no suffix (jumps); a resolvable preview → "(lightbox)"; else "(not loaded)".
771
+ const hint=(sh,prev)=>sheetLoaded(sh)?'':(prev&&previewFor(prev)?' (lightbox)':' (not loaded)');
772
+ const cob=(kc,mark,disp,sheet,prev,tip)=>`<g class=cohot data-co-sheet="${esc(sheet||'')}" data-co-prev="${esc(prev||'')}"><title>${esc(tip)}</title><circle class="cobub k-${kc}" cx="${disp[0]}" cy="${disp[1]}" r="11"/><text class=cotx x="${disp[0]}" y="${disp[1]}">${esc(String(mark==null?'':mark).slice(0,3))}</text></g>`;
773
+ for(const d of (P.details||[])){if(!Array.isArray(d.disp))continue;const sh=sheetOf(d.text),nu=numOf(d.text);
774
+ s+=cob('detail',nu||d.text,d.disp,sh,d.text,'Detail '+(nu||d.text||'')+(sh?' → '+sh:'')+hint(sh,d.text));}
775
+ for(const co of (P.callouts||[])){if(!Array.isArray(co.disp))continue;const sh=co.target&&co.target.sheet,nu=co.target&&co.target.number;
776
+ const kc=co.kind==='section'?'section':co.kind==='elevation'?'elev':'detailref';const kl=co.kind==='section'?'Section':co.kind==='elevation'?'Elevation':'Detail ref';
777
+ const prev=(nu&&sh)?(nu+'-'+sh):(co.text||'');
778
+ s+=cob(kc,co.text||nu,co.disp,sh,prev,kl+' '+(co.text||nu||'')+(sh?' → '+sh:'')+hint(sh,prev));}
779
+ }
742
780
  // numbered badges on the selected members (1..N in selection order; coincident ones fan out so duplicates are countable)
743
781
  // sized in 1/zoom units → constant on screen at any zoom (updateBadges keeps them in sync while zooming)
744
782
  const selM=selArr();
@@ -748,7 +786,7 @@ function render(){
748
786
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
749
787
  s+=renderDims();
750
788
  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)
751
- 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();
789
+ 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();
752
790
  if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
753
791
  }
754
792
  function updDup(){const n=redundantDups().length;
@@ -901,12 +939,20 @@ function panel(){
901
939
  return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"${def?' disabled':''}><span class=edec>${esc(fmtDecIn(v))}</span>`;};
902
940
  const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
903
941
  const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost title="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" title="Pick a detail callout from the drawing">⌖</button></div>`;};
942
+ // Per-end connection-library picker (sets o.conn = a connections[] row id). When set, the row's
943
+ // type/detail# are the source of truth (shown read-only below); the free-text note/detail dim to a
944
+ // fallback. Readonly combo → pick only, no free typing; the connrows source shows every row.
945
+ const cFld=(id,o)=>{const row=o.conn?connRowById(o.conn):null;
946
+ const cap=row?`<span class=edec style="text-align:left">${esc(row.type||'?')}${row.detail?' · '+esc(row.detail):''} — ${esc(row.source||'user')}</span>`
947
+ :(o.conn?'<span class=edec style="text-align:left;color:#fca5a5">references a missing row — re-pick</span>':'');
948
+ return `<div class=elab>Connection</div><input id=${id} class=combo data-src=connrows readonly placeholder="— none — (pick from the library)" value="${esc(row?connLabel(row):'')}" autocomplete=off>${cap}`;};
949
+ const fb=(o,html)=>`<div class="${o.conn?'connset':''}">${html}</div>`; // dim the note/detail fallback when a library row is referenced
904
950
  const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>'; // start=yellow, end=magenta — matches the canvas end markers
905
951
  const elev = col
906
- ? `<div class="sect f">Elevation</div>${ftFld('bos',dotS+'Bottom (BOS)',m.col.bos)}${tosFld('tosC',dotE+'Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
907
- : `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
952
+ ? `<div class="sect f">Elevation</div>${ftFld('bos',dotS+'Bottom (BOS)',m.col.bos)}${tosFld('tosC',dotE+'Top (TOS)',m.col)}${cFld('cnC',m.col)}${fb(m.col,nin('ntC',m.col.note)+dFld('dtC',m.col))}`
953
+ : `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${cFld('cnA',m.ends[0])}${fb(m.ends[0],nin('ntA',m.ends[0].note)+dFld('dtA',m.ends[0]))}
908
954
  <div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
909
- <div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
955
+ <div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${cFld('cnB',m.ends[1])}${fb(m.ends[1],nin('ntB',m.ends[1].note)+dFld('dtB',m.ends[1]))}`;
910
956
  p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
911
957
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
912
958
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
@@ -939,18 +985,19 @@ function panel(){
939
985
  const wireDet=(id,o)=>{const i=document.getElementById(id);if(i)i.onchange=e=>edit(()=>{o.detail=e.target.value.trim();});
940
986
  const pk=document.getElementById(id+'_pk');if(pk)pk.onclick=()=>{if(picking&&pickKind==='detail'&&pickEnd===o){picking=false;pickEnd=null;}else{picking=true;pickKind='detail';pickEnd=o;}render();};
941
987
  const op=document.getElementById(id+'_open');if(op)op.onclick=()=>openPreview(o.detail);};
988
+ const wireConn=(id,o)=>{const i=document.getElementById(id);if(!i)return;i.onchange=e=>edit(()=>{const v=(e.target.value||'').trim();o.conn=(!v||v==='— none —')?'':v.split(' · ')[0];});};
942
989
  if(col){
943
990
  document.getElementById('bos').onchange=e=>edit(()=>{m.col.bos=parseLen(e.target.value);});
944
991
  wireTos('tosC',m.col);
945
992
  document.getElementById('ntC').onchange=e=>edit(()=>{m.col.note=e.target.value;});
946
- wireDet('dtC',m.col);
993
+ wireDet('dtC',m.col);wireConn('cnC',m.col);
947
994
  }else{
948
995
  wireTos('tosA',m.ends[0]);
949
996
  document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
950
- wireDet('dtA',m.ends[0]);
997
+ wireDet('dtA',m.ends[0]);wireConn('cnA',m.ends[0]);
951
998
  wireTos('tosB',m.ends[1]);
952
999
  document.getElementById('ntB').onchange=e=>edit(()=>{m.ends[1].note=e.target.value;});
953
- wireDet('dtB',m.ends[1]);
1000
+ wireDet('dtB',m.ends[1]);wireConn('cnB',m.ends[1]);
954
1001
  document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});
955
1002
  }
956
1003
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // also drop any dims a mixed marquee selected
@@ -1157,6 +1204,12 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1157
1204
  return;}
1158
1205
  if(t.classList.contains('dethot')){const det=t.dataset.det;
1159
1206
  if(picking&&pickKind==='detail'&&pickEnd){const o=pickEnd;picking=false;pickEnd=null;edit(()=>{o.detail=det;});}return;}
1207
+ {const cg=t.closest('g.cohot');if(cg){const sheet=cg.dataset.coSheet,prev=cg.dataset.coPrev; // Workstream B smart jump
1208
+ const idx=sheet?C.plans.findIndex(p=>p.sheet&&p.sheet.toUpperCase()===sheet.toUpperCase()):-1;
1209
+ if(idx>=0){if(idx!==C.active)setPlan(idx);else fitToWindow();} // referenced sheet is loaded → switch to it (or re-frame if already here)
1210
+ else if(prev&&previewFor(prev))openPreview(prev); // not loaded but we have the detail image → lightbox
1211
+ else toast(sheet?('Sheet '+sheet+' isn’t loaded — open it to jump there'):'No target sheet for this callout');
1212
+ return;}}
1160
1213
  if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
1161
1214
  if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
1162
1215
  if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id;
@@ -1219,6 +1272,8 @@ document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode(
1219
1272
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
1220
1273
  function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1221
1274
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1275
+ function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
1276
+ document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
1222
1277
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
1223
1278
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
1224
1279
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
@@ -1240,6 +1295,7 @@ addEventListener('keydown',e=>{
1240
1295
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
1241
1296
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
1242
1297
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
1298
+ if(e.key==='Escape'&&connLibOpen()){closeConnLib();return;}
1243
1299
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
1244
1300
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
1245
1301
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
@@ -1428,6 +1484,37 @@ function detailsOpen(){return document.getElementById('detailsModal').style.disp
1428
1484
  document.getElementById('detailsBtn').onclick=openDetails;
1429
1485
  document.getElementById('detailsClose').onclick=closeDetails;
1430
1486
  document.getElementById('detailsBackdrop').onclick=closeDetails;
1487
+ // --- connection library: edit connections[] (type → design detail# → per-platform component id) ---
1488
+ // The library is the vendor-neutral source; member ends reference a row via ends[].conn / col.conn.
1489
+ // connPlat scopes the editable "component id" column to one platform; the detail# is platform-neutral.
1490
+ let connPlat='tekla';const PLAT_LABEL={tekla:'Tekla',sds2:'SDS2',revit:'Revit'};
1491
+ function openConnLib(){if(!Array.isArray(C.connections))C.connections=[];renderConnTable();document.getElementById('connModal').style.display='flex';}
1492
+ function closeConnLib(){document.getElementById('connModal').style.display='none';}
1493
+ function connLibOpen(){return document.getElementById('connModal').style.display==='flex';}
1494
+ function updConnBtn(){const b=document.getElementById('connBtn');if(b)b.textContent='Connections'+((C.connections||[]).length?' ('+C.connections.length+')':'');}
1495
+ function renderConnTable(){const tb=document.getElementById('connBody');if(!tb)return;
1496
+ document.getElementById('connCompHead').textContent=(PLAT_LABEL[connPlat]||connPlat)+' id';
1497
+ const rows=C.connections||[];
1498
+ if(!rows.length){tb.innerHTML='<tr><td colspan=5 style="text-align:center;padding:32px;color:var(--mut);font-size:12px">No connections defined — click + New row to add the first.</td></tr>';updConnBtn();return;}
1499
+ const srcChip=s=>s==='drawing'?'<span class="chip assumed">drawing</span>':s==='company-rules'?'<span class=chip>company-rules</span>':'<span class=chip style="border-color:#1d4ed8;color:#93c5fd">user</span>';
1500
+ tb.innerHTML=rows.map(r=>`<tr data-id="${esc(r.id)}">`
1501
+ +`<td><input class="combo ftab-inp" data-src=conntypes data-f=type value="${esc(r.type||'')}" placeholder="moment" autocomplete=off></td>`
1502
+ +`<td><input class="combo ftab-inp" data-src=details data-f=detail value="${esc(r.detail||'')}" placeholder="e.g. 50S504" autocomplete=off></td>`
1503
+ +`<td><input class=ftab-inp data-f=target value="${esc((r.targets&&r.targets[connPlat])||'')}" placeholder="e.g. 146" autocomplete=off></td>`
1504
+ +`<td class=csrc>${srcChip(r.source||'user')}</td>`
1505
+ +`<td><button class="danger cdel" title="Remove this row">✕</button></td></tr>`).join('');
1506
+ tb.querySelectorAll('tr[data-id]').forEach(tr=>{const id=tr.dataset.id;const row=connRowById(id);if(!row)return;
1507
+ tr.querySelector('[data-f=type]').onchange=e=>edit(()=>{row.type=e.target.value.trim();});
1508
+ tr.querySelector('[data-f=detail]').onchange=e=>edit(()=>{row.detail=e.target.value.trim();});
1509
+ tr.querySelector('[data-f=target]').onchange=e=>edit(()=>{if(!row.targets)row.targets={};const v=e.target.value.trim();if(v)row.targets[connPlat]=v;else delete row.targets[connPlat];});
1510
+ tr.querySelector('.cdel').onclick=()=>{edit(()=>{C.connections=C.connections.filter(x=>x.id!==id);
1511
+ for(const pl of (C.plans||[]))for(const m of (pl.members||[])){if(m.col&&m.col.conn===id)m.col.conn='';for(const en of (m.ends||[]))if(en&&en.conn===id)en.conn='';}});renderConnTable();};});
1512
+ updConnBtn();}
1513
+ document.getElementById('connBtn').onclick=openConnLib;
1514
+ document.getElementById('connClose').onclick=closeConnLib;
1515
+ document.getElementById('connBackdrop').onclick=closeConnLib;
1516
+ document.getElementById('connNew').onclick=()=>{edit(()=>{if(!Array.isArray(C.connections))C.connections=[];C.connections.push({id:'conn'+Date.now(),type:'moment',detail:'',targets:{[connPlat]:''},source:'user'});});renderConnTable();const tb=document.getElementById('connBody');if(tb.lastElementChild)tb.lastElementChild.scrollIntoView({block:'nearest'});};
1517
+ document.getElementById('connPlatGrp').querySelectorAll('button').forEach(b=>b.onclick=()=>{connPlat=b.dataset.plat;document.getElementById('connPlatGrp').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));renderConnTable();});
1431
1518
  // --- create your own detail (name + screenshot) — for anything the auto-detect missed ---
1432
1519
  function readImageCompressed(file,cb){const r=new FileReader();r.onload=()=>{const im=new Image();im.onload=()=>{
1433
1520
  const max=1400;let w=im.naturalWidth,h=im.naturalHeight;const sc=Math.min(1,max/Math.max(w,h));w=Math.max(1,Math.round(w*sc));h=Math.max(1,Math.round(h*sc));
@@ -1678,12 +1765,18 @@ lbView.addEventListener('dblclick',lbReset);
1678
1765
  const COMBO_SRC={
1679
1766
  profiles:()=>profs,
1680
1767
  conntypes:()=>['moment','drag','shear','braced','pinned','fixed'],
1681
- details:()=>{const s=new Set();(P.details||[]).forEach(d=>s.add(d.text));Object.keys(C.custom_details).forEach(n=>s.add(n));return [...s].sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));}
1768
+ details:()=>{const s=new Set();(P.details||[]).forEach(d=>s.add(d.text));Object.keys(C.custom_details).forEach(n=>s.add(n));return [...s].sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));},
1769
+ connrows:()=>['— none —',...(C.connections||[]).map(r=>connLabel(r))]
1682
1770
  };
1771
+ // A connection-library row's one-line label for the per-end picker. The id is the prefix (parsed
1772
+ // back on select); type/detail follow for the human. Kept here so COMBO_SRC + cFld + wireConn agree.
1773
+ function connLabel(r){return r.id+' · '+(r.type||'?')+(r.detail?' · '+r.detail:'');}
1774
+ function connRowById(id){return (C.connections||[]).find(r=>r&&r.id===id)||null;}
1683
1775
  const comboPop=document.createElement('div');comboPop.id='comboPop';document.body.appendChild(comboPop);
1684
1776
  let comboInput=null,comboIdx=-1;
1685
1777
  function showCombo(inp){const src=COMBO_SRC[inp.dataset.src];if(!src)return;comboInput=inp;
1686
- const q=(inp.value||'').trim().toUpperCase();const opts=src().filter(o=>String(o).toUpperCase().includes(q));
1778
+ // connrows is a row PICKER (readonly), not a type-to-filter field — always offer every row.
1779
+ const q=inp.dataset.src==='connrows'?'':(inp.value||'').trim().toUpperCase();const opts=src().filter(o=>String(o).toUpperCase().includes(q));
1687
1780
  if(!opts.length){comboPop.style.display='none';return;}
1688
1781
  comboPop.innerHTML=opts.map(o=>`<div class=opt data-v="${esc(o)}">${esc(o)}</div>`).join('');
1689
1782
  const r=inp.getBoundingClientRect();comboPop.style.left=Math.round(r.left)+'px';comboPop.style.top=Math.round(r.bottom+2)+'px';comboPop.style.minWidth=Math.round(r.width)+'px';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.48.1",
3
+ "version": "0.50.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": {