@floless/app 0.68.0 → 0.69.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.68.0" : void 0,
53025
+ define: true ? "0.69.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.68.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.69.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
package/dist/web/app.js CHANGED
@@ -360,8 +360,8 @@ function cardEl(id, ports) {
360
360
  </div>
361
361
  <div class="title">${a.title}</div>
362
362
  <div class="subtitle">${a.subtitle}</div>
363
- ${a._runtimeModel ? '<div class="rt-model-badge" title="This node calls a model at run time (vision.extract). Output is content-cached and sits behind an approve gate — review the extraction before any write.">⚡ calls a model at run time</div>' : ''}
364
- ${a._frozen ? '<div class="frozen-badge" title="Frozen — its last result is pinned and Run skips this node. Right-click to unfreeze.">❄ Frozen</div>' : ''}
363
+ ${a._runtimeModel ? '<div class="rt-model-badge" data-tip="This node calls a model at run time (vision.extract). Output is content-cached and sits behind an approve gate — review the extraction before any write.">⚡ calls a model at run time</div>' : ''}
364
+ ${a._frozen ? '<div class="frozen-badge" data-tip="Frozen — its last result is pinned and Run skips this node. Right-click to unfreeze.">❄ Frozen</div>' : ''}
365
365
  <div class="blurb">${a.blurb}</div>
366
366
  <div class="footer-row">
367
367
  <span class="ports"><span class="ports-num">${ports.in}</span> in <span class="ports-arrow">·</span> <span class="ports-num">${ports.out}</span> out</span>
package/dist/web/aware.js CHANGED
@@ -1316,7 +1316,7 @@
1316
1316
  const v = String(vals[k] || '');
1317
1317
  if (!v) return `<span class="ni-pair ni-pair-file ni-pair-file-empty"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val ni-not-set">not set</span></span>`;
1318
1318
  const glyph = /\.pdf$/i.test(v) ? 'pdf' : 'img';
1319
- return `<span class="ni-pair ni-pair-file"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-file-glyph">${glyph}</span><span class="ni-val ni-file-name" title="${escapeAttr(v)}">${escapeHtml(baseName(v))}</span></span>`;
1319
+ return `<span class="ni-pair ni-pair-file"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-file-glyph">${glyph}</span><span class="ni-val ni-file-name" data-tip="${escapeAttr(v)}">${escapeHtml(baseName(v))}</span></span>`;
1320
1320
  }
1321
1321
  return `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`;
1322
1322
  }).join('');
@@ -2305,7 +2305,7 @@
2305
2305
  if (f.type === 'current') {
2306
2306
  const cext = (/\.([a-z0-9]+)$/i.exec(f.value || '') || [])[1];
2307
2307
  const cglyph = String(cext || '').toLowerCase() === 'pdf' ? 'pdf' : 'img';
2308
- return `<div class="rebake-current"><span class="rebake-current-label">${escapeHtml(f.label || 'Currently loaded')}</span><span class="fm-file-glyph">${cglyph}</span><span class="fm-file-name" title="${escapeAttr(f.value)}">${escapeHtml(f.value)}</span></div>`;
2308
+ return `<div class="rebake-current"><span class="rebake-current-label">${escapeHtml(f.label || 'Currently loaded')}</span><span class="fm-file-glyph">${cglyph}</span><span class="fm-file-name" data-tip="${escapeAttr(f.value)}">${escapeHtml(f.value)}</span></div>`;
2309
2309
  }
2310
2310
  let ctl;
2311
2311
  if (f.type === 'images') {
@@ -2420,7 +2420,7 @@
2420
2420
  return;
2421
2421
  }
2422
2422
  const glyph = st.ext === 'pdf' ? 'pdf' : 'img';
2423
- drop.innerHTML = `<span class="fm-file-glyph">${glyph}</span><span class="fm-file-name" title="${escapeAttr(st.value)}">${escapeHtml(st.name)}</span><span class="fm-file-actions"><button type="button" class="fm-file-replace">Replace</button><button type="button" class="fm-file-clear">Clear</button></span>`;
2423
+ drop.innerHTML = `<span class="fm-file-glyph">${glyph}</span><span class="fm-file-name" data-tip="${escapeAttr(st.value)}">${escapeHtml(st.name)}</span><span class="fm-file-actions"><button type="button" class="fm-file-replace">Replace</button><button type="button" class="fm-file-clear">Clear</button></span>`;
2424
2424
  drop.querySelector('.fm-file-replace').onclick = (e) => { e.stopPropagation(); fileInput.click(); };
2425
2425
  drop.querySelector('.fm-file-clear').onclick = (e) => { e.stopPropagation(); st.mode = 'empty'; st.value = ''; st.name = ''; st.ext = ''; render(); };
2426
2426
  };
@@ -2473,7 +2473,7 @@
2473
2473
  list.hidden = state.length === 0;
2474
2474
  list.innerHTML = state.map((s, i) => s.reading
2475
2475
  ? `<div class="fm-filechip is-reading"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name">Reading…</span></div>`
2476
- : `<div class="fm-filechip"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name" title="${escapeAttr(s.name)}">${escapeHtml(s.name)}</span><button type="button" class="fm-filechip-del" data-i="${i}" aria-label="Remove ${escapeAttr(s.name)}">×</button></div>`
2476
+ : `<div class="fm-filechip"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name" data-tip="${escapeAttr(s.name)}">${escapeHtml(s.name)}</span><button type="button" class="fm-filechip-del" data-i="${i}" aria-label="Remove ${escapeAttr(s.name)}">×</button></div>`
2477
2477
  ).join('');
2478
2478
  list.querySelectorAll('.fm-filechip-del').forEach((b) => {
2479
2479
  b.onclick = () => {
@@ -375,10 +375,10 @@
375
375
  (unlike the opaque 3D viewer which uses a data: URL and allow-scripts only). -->
376
376
  <div id="contract-editor" class="contract-editor" hidden>
377
377
  <div class="contract-editor-bar">
378
- <button id="contract-editor-close" type="button">✕ Close</button>
379
378
  <span id="contract-editor-title" class="contract-editor-title"></span>
380
379
  <span style="flex:1"></span>
381
- <button id="contract-editor-approve" type="button" class="primary">✓ Approve &amp; bake lock</button>
380
+ <button id="contract-editor-close" type="button" data-tip="Close the editor and go back — your edits are already saved">Close</button>
381
+ <button id="contract-editor-approve" type="button" class="primary" data-tip="Locks in this version of the model so it can be Run. Your edits already auto-save — this is the sign-off, and Run stays disabled until you approve after each change.">✓ Approve</button>
382
382
  </div>
383
383
  <iframe id="contract-editor-frame" title="Contract editor"></iframe>
384
384
  </div>
@@ -12,6 +12,8 @@
12
12
  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
13
13
  header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
14
14
  header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
15
+ header .tb-sep{width:1px;height:20px;background:var(--line);flex:0 0 auto;align-self:center} /* thin divider grouping the header action clusters (history | tools | AI | more) */
16
+ #dim3dWrap{gap:8px} /* 3D Dimension cluster in the header — display flips inline-flex/none by view (applyViewState) */
15
17
  button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
16
18
  button:hover{background:#475569} button.on{background:var(--brand);border-color:var(--brand)}
17
19
  button:disabled{opacity:.4;cursor:default;background:#334155}
@@ -101,6 +103,20 @@
101
103
  #moreMenu .msnap-hdr .chev{color:var(--mut);font-size:10px;transition:transform .15s}
102
104
  #moreMenu .msnap-hdr[aria-expanded=true] .chev{transform:rotate(90deg)}
103
105
  #moreMenu .msnap-sect{display:none} #moreMenu .msnap-sect.open{display:block}
106
+ /* Section accordions — every ⋯ section (Snapping, Display, Detailing, …) collapses/expands like Snapping; state persists (wireMoreSections). */
107
+ #moreMenu .msec-hdr{display:flex;align-items:center;justify-content:space-between;font-weight:600;border-top:1px solid var(--line)} /* headers read as headers: semibold + a divider between sections */
108
+ #moreMenu #snapHdr{border-top:0} /* first section — no divider above it */
109
+ #moreMenu .msec-hdr .chev{color:var(--mut);font-size:10px;transition:transform .15s}
110
+ #moreMenu .msec-hdr[aria-expanded=true]{background:rgba(59,130,246,.08);color:var(--brand)} /* the OPEN section's header is brand-tinted so its (indented) items read as "inside" it, not merged with the next section */
111
+ #moreMenu .msec-hdr[aria-expanded=true] .chev{transform:rotate(90deg);color:var(--brand)}
112
+ #moreMenu .msec-hdr[aria-expanded=true]:hover{background:#334155;color:var(--text)} /* keep the normal hover feedback on an open header */
113
+ #moreMenu .msec-body{display:none} #moreMenu .msec-body.open{display:block;padding-bottom:4px}
114
+ #moreMenu .msec-body button,#moreMenu .msec-body .mhint{padding-left:26px} /* indent items so they sit visually UNDER their header */
115
+ /* Insert-detail now lives in the Detailing section; its picker flies out to the LEFT of the ⋯ menu (which hugs the right edge). */
116
+ #moreMenu .m3dwrap.ins-in-menu{display:block}
117
+ #moreMenu #m3dInsert.on{color:var(--brand)}
118
+ #moreMenu #m3dInsertMenu{left:auto;right:calc(100% + 4px);top:0}
119
+ 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) */
104
120
  #moreMenu button.msnap{display:flex;align-items:center;gap:0}
105
121
  #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) */
106
122
  #moreMenu .mck,.cmmenu .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 and the Move/Copy → Drag-to-move/copy toggle */
@@ -284,12 +300,14 @@
284
300
  .m3dmenu.open{display:block}
285
301
  .m3dmenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap;font-size:12px;box-shadow:none}
286
302
  .m3dmenu button:hover{background:#334155}
303
+ .m3dmenu button.on{color:var(--brand)} /* active choice in a radio-style menu (Camera / Display) */
304
+ .m3dmenu button.on::after{content:'✓';float:right;margin-left:16px;color:var(--brand)}
287
305
  .m3dmenu button:disabled{opacity:.4;cursor:default;background:transparent}
288
306
  .m3dmenu button.mdanger{color:#fca5a5} .m3dmenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
289
307
  .m3dmenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
290
308
  .m3dmenu label{display:flex;align-items:center;gap:7px;padding:7px 12px;color:var(--text);font-size:12px;cursor:pointer;white-space:nowrap}
291
309
  .m3dmenu label:hover{background:#334155}
292
- .m3dmenu label input{margin:0;accent-color:var(--brand);cursor:pointer}
310
+ .m3dmenu label input{margin:0;width:auto;accent-color:var(--brand);cursor:pointer} /* width:auto — don't inherit the global input{width:100%}; keeps the checkbox tight against its label (View / Plane / Work toggles) */
293
311
  .m3dmenu .wpprow{display:flex;gap:4px;align-items:center;padding:4px 12px}
294
312
  .m3dmenu .wpprow button{width:auto;flex:none;padding:4px 8px;border:1px solid var(--line);border-radius:5px}
295
313
  .m3dmenu .wpprow input{width:74px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 6px;font:12px system-ui}
@@ -382,22 +400,29 @@
382
400
  </head><body>
383
401
  <header>
384
402
  <b>Steel Model</b>
385
- <div id=viewToggle role=group aria-label="Canvas view"><button id=vt2d class="seg on" aria-pressed=true title="Plan view (2D overlay)">2D</button><button id=vt3d class=seg aria-pressed=false title="3D model view">3D</button></div>
386
- <select id=planSel title="Switch plan view"></select>
403
+ <div id=viewToggle role=group aria-label="Canvas view"><button id=vt2d class="seg on" aria-pressed=true data-tip="Plan view (2D overlay)">2D</button><button id=vt3d class=seg aria-pressed=false data-tip="3D model view">3D</button></div>
404
+ <select id=planSel data-tip="Switch plan view"></select>
387
405
  <span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
388
- <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
389
- <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
390
- <span class=stat id=csStat title="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
391
- <span class=stat id=snapStat style="display:none" title="Snap restricted for this operation — right-click the canvas to change; click here or press Esc to clear">Snap <b>—</b></span>
392
- <span class=stat id=confStat title="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
393
- <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
406
+ <span class=stat id=rfiStat data-tip="Click to list members with an unresolved size (RFI — Request for Information)">RFI <b id=rc>0</b></span>
407
+ <span class=stat id=dupStat data-tip="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
408
+ <span class=stat id=csStat data-tip="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
409
+ <span class=stat id=snapStat style="display:none" data-tip="Snap restricted for this operation — right-click the canvas to change; click here or press Esc to clear">Snap <b>—</b></span>
410
+ <span class=stat id=confStat data-tip="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
411
+ <span class=stat id=saveStat data-tip="Edits auto-save in this browser (localStorage)">Saved</span>
394
412
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
395
- <button id=undoB title="Undo (Ctrl+Z)">↶</button>
396
- <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
397
- <button id=mAdd title="Toggle add-member mode — drag to draw. Shift=ortho, Alt=no snap, right-click=restrict snap to one type">Add member</button>
398
- <button id=dimB title="Dimension tool (D)click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free. Right-click the canvas to restrict snapping to one type.">⊢ Dimension</button>
413
+ <button id=undoB data-tip="Undo (Ctrl+Z)">↶</button>
414
+ <button id=redoB data-tip="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
415
+ <span class=tb-sep></span>
416
+ <button id=mAdd data-tip="Toggle add-member modedrag to draw. Shift=ortho, Alt=no snap, right-click=restrict snap to one type">Add member</button>
417
+ <button id=dimB data-tip="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free. Right-click the canvas to restrict snapping to one type.">⊢ Dimension</button>
418
+ <!-- 3D Dimension cluster — relocated here from the 3D toolbar so Dimension has ONE home; shown only in 3D (applyViewState). Same ids so steel-3d-view.js reflectDimBar() still drives it. -->
419
+ <span id=dim3dWrap style="display:none;align-items:center;gap:8px">
420
+ <button id=m3dDim data-tip="Measure in 3D — click two snapped points (D); axis Free / X / Y / Z, Alt = vertical; right-click = restrict snap">⊢ Dimension</button>
421
+ <div class=seg-group id=m3dDimAxis style="display:none"><button data-d3axis=free class=on data-tip="Free 3D measurement (F)">Free</button><button data-d3axis=x data-tip="Lock measurement to X (X)">X</button><button data-d3axis=y data-tip="Lock measurement to Y (Y)">Y</button><button data-d3axis=z data-tip="Lock to Z / vertical (Z); Alt = quick vertical">Z</button></div>
422
+ <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
423
+ </span>
399
424
  <div class=cmwrap>
400
- <button id=xfB aria-haspopup=menu aria-expanded=false title="Move, Copy, array and to-level, plus how left-dragging a member behaves. Move (M) / Copy (C) also arm from the keyboard.">Move / Copy ▾</button>
425
+ <button id=xfB aria-haspopup=menu aria-expanded=false data-tip="Move, Copy, array and to-level, plus how left-dragging a member behaves. Move (M) / Copy (C) also arm from the keyboard.">Move / Copy ▾</button>
401
426
  <div class=cmmenu id=xfMenu role=menu>
402
427
  <div class=mlabel>Move</div>
403
428
  <button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
@@ -407,52 +432,63 @@
407
432
  <button id=cpArrB>Copy array…</button>
408
433
  <button id=cpLevelB>Copy to level…</button>
409
434
  <div class=mlabel>Dragging</div>
410
- <button id=dragMoveB role=menuitemcheckbox aria-checked=false title="When ON, left-dragging a member moves it and Ctrl+drag copies it. When OFF (default), a drag does neither — a click only selects, so members can't be nudged by accident (the Move and Copy tools still work). Applies to 2D and 3D."><span class=mck aria-hidden=true></span>Drag to move/copy</button>
435
+ <button id=dragMoveB role=menuitemcheckbox aria-checked=false data-tip="When ON, left-dragging a member moves it and Ctrl+drag copies it. When OFF (default), a drag does neither — a click only selects, so members can't be nudged by accident (the Move and Copy tools still work). Applies to 2D and 3D."><span class=mck aria-hidden=true></span>Drag to move/copy</button>
411
436
  </div>
412
437
  </div>
438
+ <span class=tb-sep></span>
413
439
  <button id=askAiBtn>Ask AI ▸</button>
440
+ <span class=tb-sep></span>
414
441
  <div id=moreWrap>
415
- <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
442
+ <button id=moreBtn data-tip="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
416
443
  <div id=moreMenu role=menu>
417
- <button id=snapHdr class=msnap-hdr aria-expanded=false aria-controls=snapSect title="Running snaps click to expand and turn each on/off (also on the snap bar, bottom-right)">Snapping<span class=chev aria-hidden=true>▸</span></button>
418
- <div id=snapSect class=msnap-sect>
419
- <button class="msnap on" data-snap=end role=menuitemcheckbox aria-checked=true title="Snap to member/segment endpoints"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>□</span>Endpoint</button>
420
- <button class="msnap on" data-snap=int role=menuitemcheckbox aria-checked=true title="Snap to where two members/segments cross"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>✕</span>Intersection</button>
421
- <button class="msnap on" data-snap=mid role=menuitemcheckbox aria-checked=true title="Snap to the midpoint of a member/segment"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>△</span>Midpoint</button>
422
- <button class="msnap on" data-snap=line role=menuitemcheckbox aria-checked=true title="Snap to the nearest point on any member/segment line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>⧗</span>Nearest</button>
423
- <button class="msnap" data-snap=ext role=menuitemcheckbox aria-checked=false title="Snap along the invisible extension of a member/line past its endpoint, with a dashed guide line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>┈</span>Extension</button>
444
+ <!-- Every section is a collapse/expand accordion (same as Snapping); state persists in localStorage (wireMoreSections). -->
445
+ <button id=snapHdr class="msnap-hdr msec-hdr" data-sec=snap aria-expanded=false aria-controls=snapSect data-tip="Running snaps — click to expand and turn each on/off (also on the snap bar, bottom-right)">Snapping<span class=chev aria-hidden=true>▸</span></button>
446
+ <div id=snapSect class="msnap-sect msec-body">
447
+ <button class="msnap on" data-snap=end role=menuitemcheckbox aria-checked=true data-tip="Snap to member/segment endpoints"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>□</span>Endpoint</button>
448
+ <button class="msnap on" data-snap=int role=menuitemcheckbox aria-checked=true data-tip="Snap to where two members/segments cross"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>✕</span>Intersection</button>
449
+ <button class="msnap on" data-snap=mid role=menuitemcheckbox aria-checked=true data-tip="Snap to the midpoint of a member/segment"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>△</span>Midpoint</button>
450
+ <button class="msnap on" data-snap=line role=menuitemcheckbox aria-checked=true data-tip="Snap to the nearest point on any member/segment line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>⧗</span>Nearest</button>
451
+ <button class="msnap" data-snap=ext role=menuitemcheckbox aria-checked=false data-tip="Snap along the invisible extension of a member/line past its endpoint, with a dashed guide line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>┈</span>Extension</button>
424
452
  <div class=mhint>Right-click the canvas any time to force one snap type for a single pick.</div>
425
453
  </div>
454
+ <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>
455
+ <div class=msec-body>
456
+ <button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">Hide dimensions</button>
457
+ <button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
458
+ <button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">Hide grid</button>
459
+ <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>
460
+ </div>
461
+ <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>
462
+ <div class=msec-body>
463
+ <button id=detailsBtn>Details</button>
464
+ <button id=connBtn data-tip="A lookup table of connection types (moment, shear, pinned, …). Each maps to a design detail and, per platform, a component ID; reference a row from each member end.">Connections</button>
465
+ <div class="m3dwrap ins-in-menu" id=insWrap>
466
+ <button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam (3D view only)">Insert detail…</button>
467
+ <div id=m3dInsertMenu class=m3dmenu role=menu></div>
468
+ <input id=insFile type=file accept="image/*" style="display:none">
469
+ </div>
470
+ <button id=bpBtn data-tip="Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.">Base plates</button>
471
+ <button id=spBtn data-tip="Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.">Shear plates</button>
472
+ <button id=framesBtn>Frames</button>
473
+ </div>
474
+ <button class=msec-hdr data-sec=checks aria-expanded=false data-tip="Find and fix modelling problems">Model checks<span class=chev aria-hidden=true>▸</span></button>
475
+ <div class=msec-body>
476
+ <button id=dupB data-tip="Selects overlapping members drawn twice by mistake — review, then press Delete to remove the extra one.">Duplicates</button>
477
+ <button id=revB data-tip="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
478
+ <button id=mrgB data-tip="Joins straight, end-to-end runs of the same profile into one beam. Undoable.">Merge collinear beams</button>
479
+ </div>
480
+ <button class=msec-hdr data-sec=coords aria-expanded=false data-tip="Local vs global drawing axes (for skewed framing)">Coordinate system<span class=chev aria-hidden=true>▸</span></button>
481
+ <div class=msec-body>
482
+ <button id=csSetB data-tip="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>
483
+ <button id=csResetB data-tip="Return to the global X/Y axes (default)" disabled>Reset to global axes</button>
484
+ </div>
485
+ <button class=msec-hdr data-sec=session aria-expanded=false data-tip="Reload from the server, or export the data">Session<span class=chev aria-hidden=true>▸</span></button>
486
+ <div class=msec-body>
487
+ <button id=reloadB data-tip="Reloads this page and pulls in any changes your terminal AI made since you last opened it.">↻ Reload from server</button>
488
+ <button id=exp>Export contract…</button>
489
+ </div>
426
490
  <hr>
427
- <div class=mlabel>Dimensions</div>
428
- <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
429
- <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
430
- <hr>
431
- <div class=mlabel>Grid</div>
432
- <button id=gridEditB title="Grid lines — a plan reference with Tekla-style spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
433
- <button id=gridToggleB title="Show or hide the grid lines in 2D and 3D">Hide grid</button>
434
- <hr>
435
- <div class=mlabel>Coordinate system</div>
436
- <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>
437
- <button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
438
- <hr>
439
- <div class=mlabel>Review</div>
440
- <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
441
- <button id=revB title="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
442
- <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
443
- <hr>
444
- <div class=mlabel>Reference</div>
445
- <button id=detailsBtn>Details</button>
446
- <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>
447
- <button id=bpBtn title="Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.">Base plates</button>
448
- <button id=spBtn title="Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.">Shear plates</button>
449
- <button id=framesBtn>Frames</button>
450
- <hr>
451
- <div class=mlabel>Session</div>
452
- <button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
453
- <button id=exp>Export contract</button>
454
- <hr>
455
- <button id=revertB class=mdanger title="Discard all saved edits and reload the detected contract">Revert</button>
491
+ <button id=revertB class=mdanger data-tip="Throws away every change you've made in this browser and reloads the original AI-read model. This can't be undone.">Revert all edits…</button>
456
492
  </div>
457
493
  </div>
458
494
  </header>
@@ -461,20 +497,37 @@
461
497
  <div id=stage><svg id=svg></svg></div>
462
498
  <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
463
499
  <div id=m3dBar role=group aria-label="3D view controls">
464
- <!-- Camera -->
465
- <div class=seg-group id=m3dProj><button data-proj=persp class=on data-tip="Perspective view — natural depth">Persp</button><button data-proj=ortho data-tip="Orthographic — true scale, no perspective">Ortho</button></div>
500
+ <!-- Camera projection — dropdown (like Plane / Work area); the button shows the current mode -->
501
+ <div class=m3dwrap>
502
+ <button id=m3dProjBtn data-tip="Camera projection — perspective, or orthographic (true scale)">Persp ▾</button>
503
+ <div id=m3dProj class=m3dmenu role=menu>
504
+ <button data-proj=persp class=on data-tip="Perspective — natural depth">Perspective</button>
505
+ <button data-proj=ortho data-tip="Orthographic — true scale, no perspective">Orthographic</button>
506
+ </div>
507
+ </div>
466
508
  <button id=m3dFit data-tip="Fit all to view (Home)">Fit</button>
509
+ <button id=m3dFitSel data-tip="Zoom to selected — frame just the selected members (Alt+Z)">Zoom sel</button>
467
510
  <span class=tb-sep></span>
468
- <!-- Display / visibility -->
469
- <div class=seg-group id=m3dMode><button data-mode=solid class=on data-tip="Solid shaded model">Solid</button><button data-mode=wire data-tip="Wireframe — edges only">Wire</button><button data-mode=xray data-tip="See-through — reveal hidden parts">X-ray</button></div>
511
+ <!-- Display mode dropdown; the button shows the current mode -->
512
+ <div class=m3dwrap>
513
+ <button id=m3dModeBtn data-tip="Display mode — solid, wireframe, or see-through (X-ray)">Solid ▾</button>
514
+ <div id=m3dMode class=m3dmenu role=menu>
515
+ <button data-mode=solid class=on data-tip="Solid shaded model">Solid</button>
516
+ <button data-mode=wire data-tip="Wireframe — edges only">Wire</button>
517
+ <button data-mode=xray data-tip="See-through — reveal hidden parts">X-ray</button>
518
+ </div>
519
+ </div>
470
520
  <button id=m3dIso data-tip="Isolate selected — hide everything else (Esc to exit)" style="display:none">Isolate</button>
471
521
  <span class=tb-sep></span>
472
- <!-- Measure / annotate -->
473
- <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
474
- <button id=m3dLabels data-tip="Show each member's mark/id label in the 3D view">Labels</button>
475
- <button id=m3dDim data-tip="Measure click two snapped points (D); axis Free/X/Y/Z, Alt = vertical; right-click = restrict snap">Dimension</button>
476
- <div class=seg-group id=m3dDimAxis style="display:none"><button data-d3axis=free class=on data-tip="Free 3D measurement (F)">Free</button><button data-d3axis=x data-tip="Lock measurement to X (X)">X</button><button data-d3axis=y data-tip="Lock measurement to Y (Y)">Y</button><button data-d3axis=z data-tip="Lock to Z / vertical (Z); Alt = quick vertical">Z</button></div>
477
- <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
522
+ <!-- Display toggles: reference lines + mark labels (grouped into a menu, like Plane / Work area).
523
+ The Dimension tool moved to the header so it lives in one place across 2D and 3D. -->
524
+ <div class=m3dwrap>
525
+ <button id=m3dView data-tip="Show or hide reference lines and mark labels on the 3D model">View ▾</button>
526
+ <div id=m3dViewMenu class=m3dmenu role=menu>
527
+ <label data-tip="Show each member's reference line (centreline)"><input type=checkbox id=m3dRef> Ref line</label>
528
+ <label data-tip="Show each member's mark / id label in the 3D view"><input type=checkbox id=m3dLabels> Labels</label>
529
+ </div>
530
+ </div>
478
531
  <span class=tb-sep></span>
479
532
  <!-- Section -->
480
533
  <div class=m3dwrap>
@@ -486,11 +539,6 @@
486
539
  <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
487
540
  </div>
488
541
  </div>
489
- <div class=m3dwrap>
490
- <button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam">Insert ▾</button>
491
- <div id=m3dInsertMenu class=m3dmenu role=menu></div>
492
- <input id=insFile type=file accept="image/*" style="display:none">
493
- </div>
494
542
  <div class=m3dwrap>
495
543
  <button id=m3dWp data-tip="Working plane — every 3D pick (Move/Copy, next: drawing) lands on it. Set it from a face, 3 points, or a principal plane.">◇ Plane ▾</button>
496
544
  <div id=m3dWpMenu class=m3dmenu role=menu>
@@ -521,19 +569,19 @@
521
569
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
522
570
  <div id=m3dAxes></div>
523
571
  <div id=zoombar>
524
- <button id=zOut title="Zoom out">−</button>
572
+ <button id=zOut data-tip="Zoom out">−</button>
525
573
  <input id=zRange type=range min=10 max=400 step=1 value=100>
526
- <button id=zIn title="Zoom in">+</button>
574
+ <button id=zIn data-tip="Zoom in">+</button>
527
575
  <span id=zPct>100%</span>
528
- <button id=zFit title="Zoom to fit (Home)">Fit</button>
576
+ <button id=zFit data-tip="Zoom to fit (Home)">Fit</button>
529
577
  </div>
530
578
  <!-- Quick-access snap toggles (same persistent state as the ⋯ menu → Snapping section; keep the two in sync — same 5 items). -->
531
579
  <div id=snapBar role=group aria-label="Snap settings">
532
- <button data-snap=end title="Endpoint snap">□</button>
533
- <button data-snap=int title="Intersection snap">✕</button>
534
- <button data-snap=mid title="Midpoint snap">△</button>
535
- <button data-snap=line title="Nearest snap">⧗</button>
536
- <button data-snap=ext title="Extension snap">┈</button>
580
+ <button data-snap=end data-tip="Endpoint snap">□</button>
581
+ <button data-snap=int data-tip="Intersection snap">✕</button>
582
+ <button data-snap=mid data-tip="Midpoint snap">△</button>
583
+ <button data-snap=line data-tip="Nearest snap">⧗</button>
584
+ <button data-snap=ext data-tip="Extension snap">┈</button>
537
585
  </div>
538
586
  </div>
539
587
  <aside id=panel></aside>
@@ -546,7 +594,7 @@
546
594
  <div id=detailNewForm style="display:none;padding:14px;border-bottom:1px solid var(--line)">
547
595
  <label class=elab for=dnName>Detail name</label>
548
596
  <input id=dnName placeholder="e.g. 5-S504 or My moment conn." autocomplete=off>
549
- <div id=dnDrop title="Paste or click to choose"><span id=dnDropTxt>Paste a screenshot (Ctrl+V) — or click to choose an image</span><img id=dnPrev alt=""></div>
597
+ <div id=dnDrop data-tip="Paste or click to choose"><span id=dnDropTxt>Paste a screenshot (Ctrl+V) — or click to choose an image</span><img id=dnPrev alt=""></div>
550
598
  <input id=dnFile type=file accept="image/*" hidden>
551
599
  <div id=dnBtns><button id=dnCancel class=ghost>Cancel</button><button id=dnAdd>Add detail</button></div>
552
600
  <div class=hint id=dnHint>For anything the auto‑detection missed. The image stays on your machine.</div>
@@ -565,7 +613,7 @@
565
613
  <div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
566
614
  <div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
567
615
  <div id=confModal><div class=mbackdrop id=confBackdrop></div>
568
- <div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt title="Confidence target for this read (%). Overrides the workflow default; saved with the contract.">Target <input id=confTarget type=number min=0 max=100 step=1> %</label><button id=confClose>✕</button></div>
616
+ <div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt data-tip="Confidence target for this read (%). Overrides the workflow default; saved with the contract.">Target <input id=confTarget type=number min=0 max=100 step=1> %</label><button id=confClose>✕</button></div>
569
617
  <div id=confCats class=conf-cats></div>
570
618
  <div id=confFilter class=conf-filter></div>
571
619
  <div id=confBody class=conf-body></div></div></div>
@@ -705,7 +753,7 @@ function showAiUpdateBanner(message) {
705
753
  const dismissBtn = document.createElement('button');
706
754
  dismissBtn.style.cssText = 'background:transparent;color:#93c5fd;border:1px solid #3b82f6;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui';
707
755
  dismissBtn.textContent = '✕';
708
- dismissBtn.title = 'Dismiss';
756
+ dismissBtn.dataset.tip = 'Dismiss';
709
757
  dismissBtn.onclick = () => bar.remove();
710
758
 
711
759
  bar.appendChild(msg);
@@ -1104,7 +1152,7 @@ function renderGridPanel(p){
1104
1152
  const nCols=planColumnCount(),hasEls=!!filterElsForPlan();
1105
1153
  p.innerHTML=`<span class=badge>Grid lines</span>
1106
1154
  <div class=hint style="margin-top:8px">No grid yet. Grid lines are a plan reference — they don't constrain members.</div>
1107
- <div class=gbtns><button id=gridReadB ${hasEls?'':'disabled'} title="${hasEls?'Detect the printed grid lines and bubbles in this sheet’s linework. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB ${nCols<2?'disabled':''} title="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button><button id=gridBlankB title="Start from a 4×4 grid of 30' bays and edit the spacings. Undoable.">Start blank</button></div>
1155
+ <div class=gbtns><button id=gridReadB ${hasEls?'':'disabled'} data-tip="${hasEls?'Detect the printed grid lines and bubbles in this sheet’s linework. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB ${nCols<2?'disabled':''} data-tip="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button><button id=gridBlankB data-tip="Start from a 4×4 grid of 30' bays and edit the spacings. Undoable.">Start blank</button></div>
1108
1156
  <div class=hint style="margin-top:10px">Spacing uses <b>n*d</b> to repeat (e.g. <b>3*25'</b> = three 25' bays). Tokens: <b>25'-6"</b>, <b>6"</b>, <b>7.5m</b>, bare mm.</div>`;
1109
1157
  const rb=document.getElementById('gridReadB');if(rb)rb.onclick=()=>gridReadFromDrawing(null);
1110
1158
  const ab=document.getElementById('gridAutoB');if(ab)ab.onclick=()=>{const g=gc.inferGrid(P.members,ppf);if(!g){toast('No columns to infer a grid from');return;}edit(()=>{P.grid=g;});};
@@ -1127,14 +1175,14 @@ function renderGridPanel(p){
1127
1175
  <div class=sect style="margin-top:10px">Labels</div>
1128
1176
  <div style="display:flex;gap:6px"><input id=gLX class=gin value="${esc(g.labels_x||'')}" placeholder="auto: 1 2 3…" autocomplete=off spellcheck=false><input id=gLY class=gin value="${esc(g.labels_y||'')}" placeholder="auto: A B C…" autocomplete=off spellcheck=false></div>
1129
1177
  <div id=gLfb class=gfb></div>
1130
- ${(!g.labels_x&&!g.labels_y)?'<div class=gbtns style="margin-top:6px"><button id=gridLabelsAiB class=ghostw title="Many drawings outline their bubble text, so the marks can’t be read deterministically. This sends a request to your AI terminal — it reads the printed marks from the source drawing and fills the labels as a contract update.">Ask AI to read the labels ▸</button></div>':''}
1178
+ ${(!g.labels_x&&!g.labels_y)?'<div class=gbtns style="margin-top:6px"><button id=gridLabelsAiB class=ghostw data-tip="Many drawings outline their bubble text, so the marks can’t be read deterministically. This sends a request to your AI terminal — it reads the printed marks from the source drawing and fills the labels as a contract update.">Ask AI to read the labels ▸</button></div>':''}
1131
1179
  <div class=sect style="margin-top:10px">Extension</div>
1132
1180
  <input id=gExt class=gin style="width:110px" value="${esc(gc.fmtLen(isFinite(g.ext)?g.ext:1524))}" autocomplete=off spellcheck=false>
1133
1181
  <div class=hint style="margin-top:3px">How far every line runs past the outermost, in plan. Drag a bubble <b>along</b> its line to override just that one.</div>
1134
- ${(()=>{const n=Object.keys(g.ends_x||{}).length+Object.keys(g.ends_y||{}).length;return n?`<div class=gfb style="display:flex;align-items:center;gap:8px;margin-top:5px"><span>${n} line${n===1?'':'s'} with a custom extent</span><button id=gridResetExtB class=ghost style="height:22px;padding:0 8px;font-size:11px" title="Reset every line back to the Extension value above. Undoable.">Reset all</button></div>`:'';})()}
1135
- <div class=gbtns><button id=gridPickB class="${gridPick?'on':''}" title="Click the grid's 1-A corner on the plan — snaps to member ends. Esc cancels.">Pick origin</button><button id=gridReadB2 ${filterElsForPlan()?'':'disabled'} title="${filterElsForPlan()?'Re-detect the printed grid from this sheet’s linework — keeps your levels and hand-set labels. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB2 ${nCols<2?'disabled':''} title="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Re-infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button></div>
1182
+ ${(()=>{const n=Object.keys(g.ends_x||{}).length+Object.keys(g.ends_y||{}).length;return n?`<div class=gfb style="display:flex;align-items:center;gap:8px;margin-top:5px"><span>${n} line${n===1?'':'s'} with a custom extent</span><button id=gridResetExtB class=ghost style="height:22px;padding:0 8px;font-size:11px" data-tip="Reset every line back to the Extension value above. Undoable.">Reset all</button></div>`:'';})()}
1183
+ <div class=gbtns><button id=gridPickB class="${gridPick?'on':''}" data-tip="Click the grid's 1-A corner on the plan — snaps to member ends. Esc cancels.">Pick origin</button><button id=gridReadB2 ${filterElsForPlan()?'':'disabled'} data-tip="${filterElsForPlan()?'Re-detect the printed grid from this sheet’s linework — keeps your levels and hand-set labels. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB2 ${nCols<2?'disabled':''} data-tip="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Re-infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button></div>
1136
1184
  ${gridPick?'<div class=hint style="margin-top:6px">Click the grid’s <b>1-A corner</b> on the plan — snaps to member ends (<b>Alt</b> off). <b>Esc</b> cancels.</div>':''}
1137
- <div class=gbtns style="margin-top:16px"><button id=gridRemoveB class=ghostw style="flex:1" title="Delete all grid lines and levels. Undoable.">Remove grid</button><button id=gridDoneB>Done</button></div>`;
1185
+ <div class=gbtns style="margin-top:16px"><button id=gridRemoveB class=ghostw style="flex:1" data-tip="Delete all grid lines and levels. Undoable.">Remove grid</button><button id=gridDoneB>Done</button></div>`;
1138
1186
  const fbSpacing=(inpId,fbId,style,override,emptyMsg)=>{
1139
1187
  const el=document.getElementById(fbId),v=document.getElementById(inpId).value;
1140
1188
  const r=gc.parseSpacings(v);
@@ -1391,7 +1439,7 @@ function panel(){
1391
1439
  <div class=sect style="margin-top:12px">Direction</div>
1392
1440
  <div class=seg2><button id=dimAxFree class="${dimAxis==='free'?'on':''}">Free</button><button id=dimAxX class="${dimAxis==='x'?'on':''}">X</button><button id=dimAxY class="${dimAxis==='y'?'on':''}">Y</button></div>
1393
1441
  <div class=sect style="margin-top:12px">Mode</div>
1394
- <div class=seg2><button id=dimChainB class="${dimChain?'on':''}" title="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
1442
+ <div class=seg2><button id=dimChainB class="${dimChain?'on':''}" data-tip="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
1395
1443
  <div class=hint style="margin-top:8px">Snaps to grid intersections, member ends, and onto member lines — same as drawing. <b>Alt</b> turns snap off. <b>Esc</b> cancels.</div>`;
1396
1444
  document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
1397
1445
  document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
@@ -1402,7 +1450,7 @@ function panel(){
1402
1450
  p.innerHTML=`<span class=badge>Dimension${selDimIds.size>1?'s · '+selDimIds.size:''}</span>
1403
1451
  ${one?`<div class=hint style="margin-top:8px">Length <b>${esc(dimValueText(dimGeo(one.a,one.b,one.axis,one.off).px))}</b> · ${({free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'})[one.axis]}</div>`:''}
1404
1452
  <div class=sect style="margin-top:12px">Edit</div>
1405
- <div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}" title="Add split point (S) — click points along the dimension to insert extra dimension points; each click splits the segment under it. Esc ends.">✂ Add split point</button></div>
1453
+ <div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}" data-tip="Add split point (S) — click points along the dimension to insert extra dimension points; each click splits the segment under it. Esc ends.">✂ Add split point</button></div>
1406
1454
  <div class=hint style="margin-top:8px">${dimSplitMode?'Click points along the dimension to split it — keep clicking to add more. <b>Alt</b> = no snap · <b>Esc</b> ends.':'Drag an end handle to re-measure · <b>Del</b> removes · <b>Esc</b> deselects.'}</div>`;
1407
1455
  document.getElementById('dimSplitB').onclick=()=>toggleDimSplit();
1408
1456
  return;}
@@ -1437,7 +1485,7 @@ function panel(){
1437
1485
  +sec('Orientation')+`<div class=detrow><label class=detf><span>Rotation °</span><input id=detRot inputmode=decimal value="${esc(String(dp.rotZ||0))}" autocomplete=off></label><label class=detf><span>Size</span><input id=detSize inputmode=decimal value="${ftin(dp.size||1000)}" autocomplete=off></label></div>`
1438
1486
  +sec('Appearance')+`<div class=detrow style="align-items:center"><span class=detf style="flex:none">Opacity</span><input id=detOpacity type=range min=0 max=100 value="${opPct}"><span id=detOpacityV class=edec style="min-width:36px;text-align:right;font-variant-numeric:tabular-nums">${opPct}%</span></div>`
1439
1487
  +`<div class=divrow><hr></div>`
1440
- +`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk title="Record a request for your terminal AI to build/adjust this detail">Ask AI to build this…</button><button class=danger id=detRemove>Remove detail</button></div>`;
1488
+ +`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk data-tip="Record a request for your terminal AI to build/adjust this detail">Ask AI to build this…</button><button class=danger id=detRemove>Remove detail</button></div>`;
1441
1489
  p.innerHTML=html;
1442
1490
  const find=()=>(C.detail_placements||[]).find(x=>x&&x.id===detId);
1443
1491
  const wr=(id,fn)=>{const i=document.getElementById(id);if(i)i.onchange=e=>fn(e.target.value);};
@@ -1490,12 +1538,12 @@ function panel(){
1490
1538
  ${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
1491
1539
  ${body}
1492
1540
  <div class=divrow><hr></div>
1493
- <div class="row f"><button class=ghostw id=partEdit title="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1541
+ <div class="row f"><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1494
1542
  const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1495
1543
  return;
1496
1544
  }}
1497
1545
  const arr=selArr();
1498
- if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" title="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
1546
+ if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" data-tip="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
1499
1547
  '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
1500
1548
  '<div class=hint style="margin:0 0 6px;font-size:11px">Level <b>'+esc(fmtFtIn(defaultTOS))+'</b> ('+esc(P.sheet)+'). '+((P.tos_callouts&&P.tos_callouts.length)?'Per-zone T.O. STEEL callouts from the drawing applied to each member; ':'')+'ends ticked <b>default</b> follow the level.</div>'+
1501
1549
  '<div class=elab>Default TOS</div><input id=defTos inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="'+esc(fmtFtIn(defaultTOS))+'"><span class=edec>'+esc(fmtDecIn(defaultTOS))+'</span>'+
@@ -1537,7 +1585,7 @@ function panel(){
1537
1585
  <div class="row hint" style="margin-top:0">Edits apply to <b>all ${arr.length}</b> selected · a blank <b>Varies</b> field is left unchanged.</div>
1538
1586
  <div class=row><label>Profile</label><input id=pf class=combo data-src=profiles placeholder="${VV(profAgg)?'Varies':''}" value="${VV(profAgg)?'':esc(profAgg||'')}" autocomplete=off></div>
1539
1587
  <div class="seg2 f"><button id=rBeam class="${allBeam?'on':''}">Beam</button><button id=rCol class="${allCol?'on':''}">Column</button></div>
1540
- <div class="seg2 mtype" id=mTypeM style="margin-top:6px" title="Set member type for all selected — drives legend grouping">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${arr.every(x=>memberTypeOf(x)===t.k)?'on':''}">${t.label}</button>`).join('')}</div>
1588
+ <div class="seg2 mtype" id=mTypeM style="margin-top:6px" data-tip="Set member type for all selected — drives legend grouping">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${arr.every(x=>memberTypeOf(x)===t.k)?'on':''}">${t.label}</button>`).join('')}</div>
1541
1589
  ${(!allBeam&&!allCol)?`<div class=hint style="margin-top:4px">Mixed — ${beams.length} beam${beams.length>1?'s':''}, ${cols.length} column${cols.length>1?'s':''}</div>`:''}
1542
1590
  <div class="row hint">Total length <b>${totalL} ft</b>${totalW!=null?` · <b>${totalW.toLocaleString()} lb</b>`:''}${dupSel?` · <span style="color:#fca5a5">${dupSel} duplicate${dupSel>1?'s':''}</span> (overlap a kept member — deleting these dedupes)`:''}</div>
1543
1591
  <div class=row><button class=ghostw id=verifyBtn${allVerified?' style="border-color:#166534;color:#86efac"':''}>${allVerified?'✓ All verified — click to unverify':'Mark all verified'}</button></div>
@@ -1548,7 +1596,7 @@ function panel(){
1548
1596
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
1549
1597
  <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim all</button></div>
1550
1598
  <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — the nearest end of <b>every</b> selected member snaps to where it meets that line.':'<b>Extend/Trim</b> (E) every selected member\'s nearest end to one line. <b>Esc</b> cancels.'}</div>
1551
- <div class=row><button class=ghostw id=swapEnds title="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></button></div>
1599
+ <div class=row><button class=ghostw id=swapEnds data-tip="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></button></div>
1552
1600
  <div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
1553
1601
  // wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
1554
1602
  document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(_wt(v)==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
@@ -1581,7 +1629,7 @@ function panel(){
1581
1629
  const tosFld=(id,label,o)=>{const def=o.tosDef!==false,v=def?defaultTOS:o.tos;
1582
1630
  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>`;};
1583
1631
  const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
1584
- 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>`;};
1632
+ 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 data-tip="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" data-tip="Pick a detail callout from the drawing">⌖</button></div>`;};
1585
1633
  // Per-end connection-library picker (sets o.conn = a connections[] row id). When set, the row's
1586
1634
  // type/detail# are the source of truth (shown read-only below); the free-text note/detail dim to a
1587
1635
  // fallback. Readonly combo → pick only, no free typing; the connrows source shows every row.
@@ -1603,7 +1651,7 @@ function panel(){
1603
1651
  ${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','1"','mm')}${pFld('weldLeg','Weld leg','5/16"','mm')}
1604
1652
  <div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
1605
1653
  ${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','1"','mm')}${pFld('embedment','Embedment','auto','mm')}${pFld('grout','Grout','auto','mm')}
1606
- <div class="row f"><button class="ghostw" id=bpRemove title="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1654
+ <div class="row f"><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1607
1655
  // This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
1608
1656
  // per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
1609
1657
  const spjs=col?[]:[0,1].map(e=>({e,j:(C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===m.id&&j.at==='end'+e)})).filter(x=>x.j);
@@ -1625,16 +1673,16 @@ function panel(){
1625
1673
  <div class=seg2 style="margin-top:0"><button id="spg_e${e}_325" class="${(j.params&&j.params.boltGrade==='A490')?'':'on'}">A325</button><button id="spg_e${e}_490" class="${(j.params&&j.params.boltGrade==='A490')?'on':''}">A490</button></div>
1626
1674
  <div class=hint style="margin:4px 0 0">Bolt length auto-sizes from the grip (AISC) → shown on the bolt callout.</div>
1627
1675
  <div class=elabrow><span class=elab>Opposite stiffener</span><label class=defck><input type=checkbox id="spck_e${e}"${st?' checked':''}>add</label></div>
1628
- <div class="row f"><button class="ghostw" id="sprm_e${e}" title="Delete this end's shear plate" style="color:#fca5a5;border-color:#7f1d1d">Remove shear plate</button></div>`;};
1676
+ <div class="row f"><button class="ghostw" id="sprm_e${e}" data-tip="Delete this end's shear plate" style="color:#fca5a5;border-color:#7f1d1d">Remove shear plate</button></div>`;};
1629
1677
  const spSect=spjs.length?`<div class=hint style="margin:8px 0 0">${spAllAuto?'Auto-added — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps. Clear all via <b>Clear shear plates</b> in the toolbar (AI-tuned plates are kept).':'Tune this end&#39;s shear plate — millimetres; empty = engine default. <b>Clearance</b> = the gap (≈½–¾") so the beam clears the support; <b>Web side</b> picks which face of the web the plate laps.'}</div>${spjs.map(spBlock).join('')}`:'';
1630
1678
  const _cps=(!col&&copesByMember[m.id])?copesByMember[m.id]:[]; // auto cope(s) on this beam (from the rendered scene)
1631
1679
  const copeSect=_cps.length?`<div class=divrow><hr><span class=sect style="margin:0">Cope (auto)</span><hr></div><div class=hint style="margin:0 0 2px">${_cps.map(esc).join(' · ')} — auto, clash-driven so the beam clears the support.</div>`:'';
1632
1680
  p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
1633
- <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>
1681
+ <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':''}" data-tip="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>
1634
1682
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
1635
- <div class="seg2 mtype" id=mTypeS style="margin-top:6px" title="Member type — drives legend grouping (independent of the Beam/Column geometry above)">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${memberTypeOf(m)===t.k?'on':''}">${t.label}</button>`).join('')}</div>
1683
+ <div class="seg2 mtype" id=mTypeS style="margin-top:6px" data-tip="Member type — drives legend grouping (independent of the Beam/Column geometry above)">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${memberTypeOf(m)===t.k?'on':''}">${t.label}</button>`).join('')}</div>
1636
1684
  <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
1637
- <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
1685
+ <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} data-tip="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
1638
1686
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
1639
1687
  ${elev}
1640
1688
  ${bpSect}${spSect}${copeSect}
@@ -1644,7 +1692,7 @@ function panel(){
1644
1692
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
1645
1693
  <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim</button><button id=geoSplit class="${geoMode==='split'?'on':''}">Split</button></div>
1646
1694
  <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — another member or a grey segment. The nearest end of this member snaps to where the two lines meet (extends if short, trims if it overshoots).':geoMode==='split'?'Click a <b>point on this member</b> to cut it into two members.':'<b>Extend/Trim</b> (E) an end to meet another line · <b>Split</b> (S) at a point. <b>Esc</b> cancels.'}</div>
1647
- <div class=row><button class=ghostw id=swapEnds title="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
1695
+ <div class=row><button class=ghostw id=swapEnds data-tip="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
1648
1696
  <div class="row f"><button class=danger id=del>Delete member</button></div>`;
1649
1697
  document.getElementById('pf').onchange=e=>edit(()=>{const v=e.target.value.toUpperCase().replace(/ /g,'');m.profile=v;m.rfi=(_wt(v)==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});
1650
1698
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
@@ -1876,9 +1924,9 @@ function propPopOpen(){const el=document.getElementById('propPop');return !!(el&
1876
1924
  function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
1877
1925
  el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
1878
1926
  el.innerHTML=`<div class=pph><b id=ppTitle>Properties</b><span class=pcount id=ppLabeled></span>`
1879
- +`<button class=pin id=ppPin title="Pin — keep open across selections">📌</button>`
1880
- +`<button id=ppClear title="Uncheck every label">Clear all</button>`
1881
- +`<button id=ppClose title="Close (Esc)">✕</button></div>`
1927
+ +`<button class=pin id=ppPin data-tip="Pin — keep open across selections">📌</button>`
1928
+ +`<button id=ppClear data-tip="Uncheck every label">Clear all</button>`
1929
+ +`<button id=ppClose data-tip="Close (Esc)">✕</button></div>`
1882
1930
  +`<div class=ppsearch><input id=ppSearch placeholder="Search properties…" autocomplete=off aria-label="Search properties"></div>`
1883
1931
  +`<div class=ppmeta id=ppMeta></div><div class=ppscope id=ppScope></div>`
1884
1932
  +`<div class=pplist id=ppList></div>`
@@ -1918,7 +1966,7 @@ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el
1918
1966
  if(q&&!def.label.toLowerCase().includes(q))return '';
1919
1967
  shown++;const r=propAggRow(def,arr),checked=pl.props.includes(def.key),dis=r.state==='na';
1920
1968
  const val=r.state==='val'?esc(r.text):r.state==='varies'?'Varies':'—',vc=r.state==='varies'?' varies':'';
1921
- return `<div class="pprow${dis?' dis':''}" data-k="${def.key}"${dis?' title="Nothing to label — no value in the selection"':''}>`
1969
+ return `<div class="pprow${dis?' dis':''}" data-k="${def.key}"${dis?' data-tip="Nothing to label — no value in the selection"':''}>`
1922
1970
  +`<input type=checkbox ${checked?'checked':''}${dis?' disabled':''} aria-label="${esc(def.label)}">`
1923
1971
  +`<span class=pn>${esc(def.label)}</span><span class="pv${vc}">${val}</span></div>`;}).join('');
1924
1972
  el.querySelector('#ppList').innerHTML=rows||'<div class=ppempty>No properties match your search.</div>';
@@ -2284,7 +2332,7 @@ function axisGlyphSvg(o,dir,preview){
2284
2332
  +tag(lx,'#ef4444','X')+tag(ly,'#22c55e','Y')+`</g>`;}
2285
2333
  // header chip + the More-menu "Reset to global" enabled state — reflect the current frame.
2286
2334
  function updCS(){const s=document.getElementById('csStat');
2287
- if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.title=f?'Local coordinate frame active — ortho draw & X/Y dimensions follow it. Reset via the ⋯ menu.':'Global axes (default). Set a local frame from the ⋯ menu for skewed framing.';}
2335
+ if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.dataset.tip=f?'Local coordinate frame active — ortho draw & X/Y dimensions follow it. Reset via the ⋯ menu.':'Global axes (default). Set a local frame from the ⋯ menu for skewed framing.';}
2288
2336
  const r=document.getElementById('csResetB');if(r)r.disabled=!(P&&P.frame);}
2289
2337
  // themed transient toast (baseline tokens — never a native alert)
2290
2338
  function toast(msg){let t=document.getElementById('toast');
@@ -2567,10 +2615,13 @@ function moreOpen(){return moreMenu.classList.contains('open');}
2567
2615
  function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
2568
2616
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
2569
2617
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
2570
- moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msnap-hdr'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles + the "Snapping" expander keep the menu open (settings, not one-shot actions)
2618
+ 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)
2571
2619
  // "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
2572
- {const snapHdr=document.getElementById('snapHdr'),snapSect=document.getElementById('snapSect');
2573
- snapHdr.onclick=e=>{e.stopPropagation();const open=snapSect.classList.toggle('open');snapHdr.setAttribute('aria-expanded',String(open));};}
2620
+ // Every ⋯ section is a collapse/expand accordion (Snapping + Display + Detailing + …); state persists in localStorage.
2621
+ {const SEC_KEY='steelMoreSections';let openSecs;try{openSecs=new Set(JSON.parse(localStorage.getItem(SEC_KEY)||'[]'));}catch(e){openSecs=new Set();}
2622
+ function setSec(hdr,open){const body=hdr.nextElementSibling;if(!body)return;body.classList.toggle('open',open);hdr.setAttribute('aria-expanded',String(open));const k=hdr.dataset.sec;if(k){open?openSecs.add(k):openSecs.delete(k);try{localStorage.setItem(SEC_KEY,JSON.stringify([...openSecs]));}catch(e){}}}
2623
+ document.querySelectorAll('#moreMenu .msec-hdr').forEach(hdr=>{setSec(hdr,openSecs.has(hdr.dataset.sec)); // restore
2624
+ hdr.onclick=e=>{e.stopPropagation();setSec(hdr,!hdr.nextElementSibling.classList.contains('open'));};});}
2574
2625
  // --- Running-snaps: one source of truth (snapEnabled) rendered onto TWO surfaces — the ⋯ menu switches
2575
2626
  // (aria-checked) + the quick-access snap bar (aria-pressed). toggleSnap() is the only writer; both surfaces
2576
2627
  // call it, so they can't drift. The transient right-click override is separate and never writes here. ---
@@ -2677,7 +2728,7 @@ const view3dApi={
2677
2728
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
2678
2729
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
2679
2730
  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)
2680
- onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'Insert ':'Insert ';}}, // mirror the clip arm/cancel affordance
2731
+ onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?' Cancel insert':'Insert detail…';}}, // armed cancel target
2681
2732
  onInsertPlace:(pick,pending)=>{if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
2682
2733
  const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
2683
2734
  const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
@@ -2814,7 +2865,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
2814
2865
  if(!groups.length){host.style.display='none';return;}
2815
2866
  const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide/show · dbl-click isolate · Ctrl/Shift multi';host.appendChild(hint);
2816
2867
  const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
2817
- if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.title='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
2868
+ if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.dataset.tip='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
2818
2869
  const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
2819
2870
  row.append(sw,document.createTextNode(g.label));
2820
2871
  row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
@@ -2825,7 +2876,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
2825
2876
  // getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
2826
2877
  const buildCatHeader=(cat,label,count,opts)=>{opts=opts||{};const hdr=document.createElement('div');hdr.className='cat-hdr'+(opts.empty?' empty':'')+(opts.sub?' sub':'');hdr.dataset.cat=cat;hdr._getState=opts.getState;
2827
2878
  const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:collapsedCats.has(cat)?'▶':'▼'});
2828
- const tog=Object.assign(document.createElement('span'),{className:'cat-tog',title:opts.toggleTitle||('Show / hide all '+label.toLowerCase())});if(opts.empty||!opts.onToggle)tog.style.display='none';
2879
+ const tog=Object.assign(document.createElement('span'),{className:'cat-tog'});tog.dataset.tip=opts.toggleTitle||('Show / hide all '+label.toLowerCase());if(opts.empty||!opts.onToggle)tog.style.display='none';
2829
2880
  const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
2830
2881
  const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
2831
2882
  hdr.append(chev,tog,lab,cnt);
@@ -2903,7 +2954,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
2903
2954
  // the grid's colour, not the cyan dim overlays.
2904
2955
  if(typeof P!=='undefined'&&P&&P.grid){
2905
2956
  host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
2906
- const grow=document.createElement('div');grow.className='lrow dim';grow.title='Show / hide the structural grid (2D + 3D)';
2957
+ const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
2907
2958
  const gsw=document.createElement('span');gsw.className='lsw';gsw.style.borderColor='#64748b';
2908
2959
  grow.append(gsw,document.createTextNode('Grid lines'));
2909
2960
  grow.classList.toggle('dimoff',!gridOn());
@@ -2923,10 +2974,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
2923
2974
  else for(const c of clips){
2924
2975
  // Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
2925
2976
  const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':''); // enable state is shown by the On/Off pill, not by dimming the row
2926
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.title='Select — show its drag handles in 3D'; // box = lighter blue, plane = brand blue
2927
- const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.title='Click to select · double-click to rename';
2928
- const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.title='Enable / disable this clip';
2929
- const x=document.createElement('span');x.className='lx';x.textContent='×';x.title='Delete this clip';
2977
+ const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.dataset.tip='Select — show its drag handles in 3D'; // box = lighter blue, plane = brand blue
2978
+ const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
2979
+ const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
2980
+ const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
2930
2981
  row.append(sw,lab,tog,x);
2931
2982
  sw.addEventListener('click',e=>{e.stopPropagation();clipSelect(c.id,e);}); // Ctrl/Shift = multi-select (same as parts/dims)
2932
2983
  let clipClickT=null;
@@ -2960,11 +3011,24 @@ function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3D
2960
3011
  document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles too
2961
3012
  let bar3dWired=false;
2962
3013
  function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
3014
+ // Reflect the live projection / display mode into the Camera + Display dropdowns: tick the active menu item AND label the trigger button, so the current mode shows without opening the menu.
3015
+ function reflectProj(){const V=window.Steel3DView;if(!V)return;const p=V.projection();seg3dActive('#m3dProj','data-proj',p);const b=document.getElementById('m3dProjBtn');if(b)b.textContent=(p==='ortho'?'Ortho':'Persp')+' ▾';}
3016
+ function reflectMode(){const V=window.Steel3DView;if(!V)return;const m=V.mode();seg3dActive('#m3dMode','data-mode',m);const b=document.getElementById('m3dModeBtn');if(b)b.textContent=(m==='wire'?'Wire':m==='xray'?'X-ray':'Solid')+' ▾';}
2963
3017
  function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2964
- document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
2965
- document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
3018
+ // Camera + Display dropdowns — open/close like Clip/Work/Plane; picking an item sets the mode, ticks it, and relabels the trigger (reflectProj/reflectMode).
3019
+ const projBtn=document.getElementById('m3dProjBtn'),projMenu=document.getElementById('m3dProj');
3020
+ function projMenuOutside(e){if(!projMenu.contains(e.target)&&e.target!==projBtn)projMenuClose();}
3021
+ function projMenuClose(){projMenu.classList.remove('open');document.removeEventListener('mousedown',projMenuOutside,true);}
3022
+ projBtn.onclick=e=>{e.stopPropagation();if(projMenu.classList.contains('open'))projMenuClose();else{projMenu.classList.add('open');document.addEventListener('mousedown',projMenuOutside,true);}};
3023
+ projMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);reflectProj();projMenuClose();});
3024
+ const modeBtn=document.getElementById('m3dModeBtn'),modeMenu=document.getElementById('m3dMode');
3025
+ function modeMenuOutside(e){if(!modeMenu.contains(e.target)&&e.target!==modeBtn)modeMenuClose();}
3026
+ function modeMenuClose(){modeMenu.classList.remove('open');document.removeEventListener('mousedown',modeMenuOutside,true);}
3027
+ modeBtn.onclick=e=>{e.stopPropagation();if(modeMenu.classList.contains('open'))modeMenuClose();else{modeMenu.classList.add('open');document.addEventListener('mousedown',modeMenuOutside,true);}};
3028
+ modeMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);reflectMode();modeMenuClose();});
2966
3029
  document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
2967
- document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};
3030
+ document.getElementById('m3dFitSel').onclick=()=>window.Steel3DView.frameSelection(); // Zoom to selected (Alt+Z already bound in steel-3d-view.js)
3031
+ document.getElementById('m3dRef').onchange=e=>window.Steel3DView.setRefLine(e.target.checked); // View ▾ menu checkbox
2968
3032
  const d3=window.Steel3DView;
2969
3033
  document.getElementById('m3dDim').onclick=()=>{d3.toggleDimTool();setLastCmd('Dimension',()=>{const b=document.getElementById('m3dDim');if(b&&!b.classList.contains('on'))d3.toggleDimTool();});}; // toggleDimTool()'s reflectDimBar() owns the button .on class + axis/show visibility (single source, no duplicate DOM toggles)
2970
3034
  document.querySelectorAll('#m3dDimAxis button').forEach(b=>b.onclick=()=>{d3.setDimAxis(b.dataset.d3axis);seg3dActive('#m3dDimAxis','data-d3axis',b.dataset.d3axis);});
@@ -2980,8 +3044,16 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2980
3044
  else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
2981
3045
  clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
2982
3046
  if(a==='plane'){d3.setClipMode('plane');setLastCmd('Clip plane',()=>d3.setClipMode('plane'));}else if(a==='box'){d3.setClipMode('box');setLastCmd('Clip box',()=>d3.setClipMode('box'));}else if(a==='clear')d3.clearClips();});
2983
- // Labels: a plain toggle (mirrors Ref line) — show each member's mark/id in the 3D view.
2984
- document.getElementById('m3dLabels').onclick=()=>{const on=!d3.labelsOn();d3.setLabelsOn(on);document.getElementById('m3dLabels').classList.toggle('on',on);};
3047
+ // Labels: a checkbox in the View ▾ menu (mirrors Ref line) — show each member's mark/id in the 3D view.
3048
+ document.getElementById('m3dLabels').onchange=e=>d3.setLabelsOn(e.target.checked);
3049
+ // View menu: groups the Ref line + Labels toggles (same open/close discipline as Clip / Work area / Plane).
3050
+ const viewBtn=document.getElementById('m3dView'),viewMenu=document.getElementById('m3dViewMenu');
3051
+ function viewMenuOutside(e){if(!viewMenu.contains(e.target)&&e.target!==viewBtn)viewMenuClose();}
3052
+ function viewMenuClose(){viewMenu.classList.remove('open');document.removeEventListener('mousedown',viewMenuOutside,true);}
3053
+ viewBtn.onclick=e=>{e.stopPropagation();
3054
+ if(viewMenu.classList.contains('open'))viewMenuClose();
3055
+ else{document.getElementById('m3dRef').checked=!!d3.refLine();document.getElementById('m3dLabels').checked=!!d3.labelsOn(); // reflect live state on open
3056
+ viewMenu.classList.add('open');document.addEventListener('mousedown',viewMenuOutside,true);}};
2985
3057
  // Insert a detail: the ▾ menu lists the detail library + an "add image" option; picking one arms a
2986
3058
  // placement pick (onInsertPlace drops it). While armed the button is a cancel target (onInsertModeChange).
2987
3059
  // The menu is built with DOM nodes (textContent) — the detail names are user text, never innerHTML.
@@ -2991,10 +3063,10 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2991
3063
  function insMenuBuild(){const names=Object.keys(C.custom_details||{}).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
2992
3064
  const frag=document.createDocumentFragment();
2993
3065
  const lab=document.createElement('div');lab.className='mlabel';lab.textContent='Place a detail';frag.appendChild(lab);
2994
- if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.title='Place '+n;b.onclick=()=>{insMenuClose();armInsert(n);};frag.appendChild(b);}}
3066
+ if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.dataset.tip='Place '+n;b.onclick=()=>{insMenuClose();closeMore();armInsert(n);};frag.appendChild(b);}}
2995
3067
  else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
2996
3068
  frag.appendChild(document.createElement('hr'));
2997
- const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();document.getElementById('insFile').click();};frag.appendChild(add);
3069
+ const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();closeMore();document.getElementById('insFile').click();};frag.appendChild(add);
2998
3070
  insMenu.replaceChildren(frag);}
2999
3071
  insBtn.onclick=e=>{e.stopPropagation();
3000
3072
  if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
@@ -3041,6 +3113,9 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
3041
3113
  document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
3042
3114
  document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
3043
3115
  document.getElementById('m3dBar').style.display=on?'flex':'none';
3116
+ document.body.classList.toggle('v3d',on); // 3D-only chrome (e.g. ⋯ → Detailing → Insert detail) keys off this
3117
+ document.getElementById('dimB').style.display=on?'none':''; // 2D Dimension button — 2D only
3118
+ document.getElementById('dim3dWrap').style.display=on?'inline-flex':'none'; // 3D Dimension cluster (moved to header) — 3D only
3044
3119
  document.getElementById('m3dCube').style.display=on?'block':'none';
3045
3120
  document.getElementById('m3dAxes').style.display=on?'block':'none';
3046
3121
  document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
@@ -3059,7 +3134,7 @@ async function setView(on){
3059
3134
  await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
3060
3135
  window.Steel3DView.setSelection(selIds);
3061
3136
  wire3DBar();build3DLegend();
3062
- seg3dActive('#m3dProj','data-proj',window.Steel3DView.projection());seg3dActive('#m3dMode','data-mode',window.Steel3DView.mode()); // reflect persisted state
3137
+ reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
3063
3138
  }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
3064
3139
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
3065
3140
  toast('Could not open 3D view: '+((e&&e.message)||e));
@@ -3114,7 +3189,7 @@ function openDetails(){const g=document.getElementById('detailsGrid');const uniq
3114
3189
  g.innerHTML=uniq.length?uniq.map(t=>{const custom=C.custom_details[t]!=null;const sheet=custom?'':(sheetOf(t)||'');const pv=previewFor(t),b=custom?null:bubbleFor(t);
3115
3190
  const prev=pv?(b?`<img class=dthumb data-t="${esc(t)}" alt="${esc(t)}">`:`<img src="data:image/jpeg;base64,${pv}" alt="${esc(t)}">`):`<div class=dprev>Preview pending<br>from sheet ${esc(sheet)}</div>`;
3116
3191
  const sub=custom?'Custom detail':('Sheet '+esc(sheet)+(b?' · detail '+esc(numOf(t)):''));
3117
- return `<div class=dcard><div class=dprevwrap${pv?` data-det="${esc(t)}"`:''}>${prev}</div><div class=dmeta><input class=dname data-old="${esc(t)}" value="${esc(t)}" title="Rename (updates every plan)" autocomplete=off><div class=ds>${sub}${custom?` · <a href="#" class=ddel data-del="${esc(t)}">delete</a>`:''}</div></div></div>`;}).join('')
3192
+ return `<div class=dcard><div class=dprevwrap${pv?` data-det="${esc(t)}"`:''}>${prev}</div><div class=dmeta><input class=dname data-old="${esc(t)}" value="${esc(t)}" data-tip="Rename (updates every plan)" autocomplete=off><div class=ds>${sub}${custom?` · <a href="#" class=ddel data-del="${esc(t)}">delete</a>`:''}</div></div></div>`;}).join('')
3118
3193
  :'<div class=hint>No detail callouts detected. Use + New detail to add your own.</div>';
3119
3194
  g.querySelectorAll('img.dthumb').forEach(im=>detThumb(im.dataset.t,im));
3120
3195
  g.querySelectorAll('.dprevwrap[data-det]').forEach(c=>c.onclick=()=>openPreview(c.dataset.det));
@@ -3145,7 +3220,7 @@ function updBpBtn(){const b=document.getElementById('bpBtn');if(!b)return;
3145
3220
  const colSet=new Set(colMemberIds()), n=autoBasePlates().length;
3146
3221
  const covered=new Set((C.joints||[]).filter(j=>j&&j.kind==='base-plate'&&colSet.has(j.main)).map(j=>j.main)).size; // only CURRENT columns (ignore joints orphaned by a deleted column)
3147
3222
  b.disabled=colSet.size===0; // no columns → nothing to detail (empty-state)
3148
- b.title=colSet.size===0?'No columns in this model to detail base plates.':'Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.';
3223
+ b.dataset.tip=colSet.size===0?'No columns in this model to detail base plates.':'Auto-detail base plates on every column (a plate + anchor kit + weld, shown in 3D). Tune sizes per the schedule via the AI or per-column params.';
3149
3224
  b.textContent=((colSet.size>0&&covered>=colSet.size&&n>0)?'Clear base plates':'Base plates')+(n?' ('+n+')':'');} // label flips once all current columns are covered, so the destructive 2nd click reads clearly
3150
3225
  // C.joints is model-global but now rides the snapshot (snapshot()/apply() carry it), so every joint
3151
3226
  // mutation goes through edit() — auto-detail, the inspector, and Remove are all undoable (Ctrl+Z/Y).
@@ -3190,7 +3265,7 @@ function updSpBtn(){const b=document.getElementById('spBtn');if(!b)return;
3190
3265
  const elig=spEligibleEnds(),n=autoShearPlates().length;
3191
3266
  const covered=elig.filter(({beam,endIdx})=>spJointOf(beam.id,endIdx)).length; // eligible ends already detailed (auto OR user)
3192
3267
  b.disabled=elig.length===0; // no eligible beam ends → nothing to detail (empty-state, disabled-not-hidden)
3193
- b.title=elig.length===0?'No eligible beam ends in this model to detail shear plates.':'Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.';
3268
+ b.dataset.tip=elig.length===0?'No eligible beam ends in this model to detail shear plates.':'Auto-detail bolted shear (fin) plates on eligible beam ends (a fin plate + bolt group + weld, shown in 3D). Tune sizes per the schedule via the AI or per-end params.';
3194
3269
  b.textContent=((elig.length>0&&covered>=elig.length&&n>0)?'Clear shear plates':'Shear plates')+(n?' ('+n+')':'');} // label flips to Clear once all ELIGIBLE ends are covered (moment ends are legitimately excluded)
3195
3270
  function toggleShearPlates(){
3196
3271
  const elig=spEligibleEnds();
@@ -3214,7 +3289,7 @@ function renderConnTable(){const tb=document.getElementById('connBody');if(!tb)r
3214
3289
  +`<td><input class="combo ftab-inp" data-src=details data-f=detail value="${esc(r.detail||'')}" placeholder="e.g. 50S504" autocomplete=off></td>`
3215
3290
  +`<td><input class=ftab-inp data-f=target value="${esc((r.targets&&r.targets[connPlat])||'')}" placeholder="e.g. 146" autocomplete=off></td>`
3216
3291
  +`<td class=csrc>${srcChip(r.source||'user')}</td>`
3217
- +`<td><button class="danger cdel" title="Remove this row">✕</button></td></tr>`).join('');
3292
+ +`<td><button class="danger cdel" data-tip="Remove this row">✕</button></td></tr>`).join('');
3218
3293
  tb.querySelectorAll('tr[data-id]').forEach(tr=>{const id=tr.dataset.id;const row=connRowById(id);if(!row)return;
3219
3294
  tr.querySelector('[data-f=type]').onchange=e=>edit(()=>{row.type=e.target.value.trim();});
3220
3295
  tr.querySelector('[data-f=detail]').onchange=e=>edit(()=>{row.detail=e.target.value.trim();});
@@ -3384,7 +3459,7 @@ function updConf(){const el=document.getElementById('confStat');if(!el)return;co
3384
3459
  else{const gap=tgt-sc;
3385
3460
  tg.innerHTML=' · <span style="color:var(--mut)">target '+tgt+'%</span>'+(gap>0?' <span style="color:'+col+'">(−'+gap+'%)</span>':'');}
3386
3461
  }
3387
- el.title='Confidence '+(sc==null?'—':sc+'%')+(tgt!=null?' vs target '+tgt+'%':'')+' — '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
3462
+ el.dataset.tip='Confidence '+(sc==null?'—':sc+'%')+(tgt!=null?' vs target '+tgt+'%':'')+' — '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
3388
3463
  // --- the report modal ---
3389
3464
  let confBand='all',confCat='all',confExpand=null;
3390
3465
  function openConf(){confExpand=null;renderConf();document.getElementById('confModal').style.display='flex';}
@@ -3395,7 +3470,7 @@ function _countsHtml(c){const out=['verified','high','med','low','rfi'].filter(k
3395
3470
  function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
3396
3471
  const ct=document.getElementById('confTarget');if(ct&&document.activeElement!==ct){ // value = the per-read OVERRIDE only; the app default shows as a placeholder (empty = using default)
3397
3472
  ct.value=typeof C.target_confidence==='number'?C.target_confidence:'';ct.placeholder=TARGET_CONF!=null?String(TARGET_CONF):'70';}
3398
- const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' title="not scored yet"':'')+'>'+
3473
+ const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' data-tip="not scored yet"':'')+'>'+
3399
3474
  '<div class=cc-label>'+label+'</div>'+
3400
3475
  '<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
3401
3476
  _bar(stub?null:cs.score)+
@@ -3673,7 +3748,7 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
3673
3748
  if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
3674
3749
  if(!parts.length)return;
3675
3750
  const el=document.getElementById('srcStat');if(!el)return;
3676
- el.textContent=parts.join(' · ');el.title=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
3751
+ el.textContent=parts.join(' · ');el.dataset.tip=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
3677
3752
  })();
3678
3753
  // --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
3679
3754
  // The server records a tweak-contract request; the terminal AI picks it up async and PUTs
@@ -3725,7 +3800,7 @@ function askAiRenderThumbs() {
3725
3800
  img.src = snap.dataUrl; // full dataURL — safe, not user text, no textContent needed
3726
3801
  img.alt = '';
3727
3802
  const rmBtn = document.createElement('button');
3728
- rmBtn.title = 'Remove';
3803
+ rmBtn.dataset.tip = 'Remove';
3729
3804
  rmBtn.textContent = '✕';
3730
3805
  rmBtn.onclick = () => { askAiSnapshots.splice(idx, 1); askAiRenderThumbs(); };
3731
3806
  const nameEl = document.createElement('div');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.68.0",
3
+ "version": "0.69.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": {
@@ -25,6 +25,7 @@
25
25
  "build:exe": "node build/bundle.mjs && node build/make-sea.mjs",
26
26
  "verify:skills": "node build/verify-shipped-skills.mjs",
27
27
  "verify:flo": "node build/verify-flo-descriptions.mjs",
28
+ "verify:no-title": "node build/verify-no-title.mjs",
28
29
  "typecheck": "tsc --noEmit",
29
30
  "prepack": "npm run build"
30
31
  },