@floless/app 0.34.2 → 0.35.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.
@@ -97,6 +97,9 @@
97
97
  .mpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;width:min(880px,92vw);max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
98
98
  .mhead{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line)}
99
99
  .mhead b{font-size:14px} .mhead button{padding:2px 9px}
100
+ .conf-tgt{display:flex;align-items:center;gap:6px;margin-left:auto;margin-right:12px;font-size:12px;color:var(--mut)}
101
+ #confTarget{width:54px;text-align:right;padding:4px 6px;font:13px system-ui;-moz-appearance:textfield;appearance:textfield}
102
+ #confTarget::-webkit-outer-spin-button,#confTarget::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
100
103
  .mgrid{padding:14px;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:12px}
101
104
  .dcard{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--bg)}
102
105
  .dcard .dprev{height:120px;display:flex;align-items:center;justify-content:center;background:#0b1220;color:var(--mut);font-size:11px;text-align:center;padding:8px;line-height:1.4}
@@ -133,19 +136,32 @@
133
136
  #dupStat.has b{color:#fca5a5} #dupB.alert{background:#7f1d1d;border-color:#991b1b} #dupB.alert:hover{background:#991b1b}
134
137
  #dupB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
135
138
  header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
136
- </style></head><body>
139
+ /* 2D|3D view toggle — segmented, far-left; active segment filled (--panel) with a --brand underline */
140
+ #viewToggle{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:none}
141
+ #viewToggle .seg{padding:5px 11px;margin:0;border:0;border-radius:0;background:transparent;color:var(--mut);font:600 12px system-ui;cursor:pointer}
142
+ #viewToggle .seg+.seg{border-left:1px solid var(--line)}
143
+ #viewToggle .seg:hover{color:var(--text)}
144
+ #viewToggle .seg.on{background:var(--panel);color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
145
+ #stagewrap{position:relative}
146
+ #stage3d{position:absolute;inset:0;width:100%;height:100%;display:none;outline:none}
147
+ </style>
148
+ <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/"}}</script>
149
+ <script type="module" src="./steel-3d-view.js"></script>
150
+ </head><body>
137
151
  <header>
138
152
  <b>Steel takeoff</b>
153
+ <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>
139
154
  <select id=planSel title="Switch plan view"></select>
140
155
  <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>
141
156
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
142
157
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
143
- <span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
158
+ <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>
144
159
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
145
160
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
146
161
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
147
162
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
148
163
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
164
+ <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
149
165
  <button id=mAdd title="Toggle add-member mode">Add member</button>
150
166
  <button id=detailsBtn>Details</button>
151
167
  <button id=framesBtn>Frames</button>
@@ -157,6 +173,7 @@
157
173
  <main>
158
174
  <div id=stagewrap>
159
175
  <div id=stage><svg id=svg></svg></div>
176
+ <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
160
177
  <div id=zoombar>
161
178
  <button id=zOut title="Zoom out">−</button>
162
179
  <input id=zRange type=range min=10 max=400 step=1 value=100>
@@ -188,7 +205,7 @@
188
205
  <div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
189
206
  <div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
190
207
  <div id=confModal><div class=mbackdrop id=confBackdrop></div>
191
- <div class=mpanel><div class=mhead><b>Confidence report</b><button id=confClose>✕</button></div>
208
+ <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>
192
209
  <div id=confCats class=conf-cats></div>
193
210
  <div id=confFilter class=conf-filter></div>
194
211
  <div id=confBody class=conf-body></div></div></div>
@@ -215,6 +232,8 @@
215
232
  <script>
216
233
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
217
234
  let C, PAL, WT;
235
+ let TARGET_CONF = null; // the app's target_confidence input default (%), or null when undeclared — drives the chip's "· target N%" goal
236
+ let view3d = false, view3dReady = false; // 2D|3D toggle state; view3dReady once Steel3DView.init has run
218
237
  // Fallback palette (colorFor indexes this array by profile order) when a contract ships no `palette`
219
238
  // — e.g. a contract shared/committed with raster_b64 + palette stripped for confidentiality.
220
239
  const DEFAULT_PAL=['#60a5fa','#94a3b8','#f59e0b','#34d399','#f472b6','#a78bfa','#fb7185','#22d3ee','#facc15','#4ade80'];
@@ -242,6 +261,16 @@ async function boot() {
242
261
  return;
243
262
  }
244
263
  C = await res.json();
264
+ // The confidence target is a workflow INPUT (target_confidence), not contract data — read its
265
+ // declared default off the app def so the chip can show the goal. Best-effort: on any failure the
266
+ // chip just omits the "· target N%" suffix (no fabricated goal).
267
+ try {
268
+ const ar = await fetch('/api/app/' + encodeURIComponent(APP_ID));
269
+ if (ar.ok) {
270
+ const ti = ((await ar.json())?.app?.inputs || []).find(i => i && i.name === 'target_confidence');
271
+ if (ti && typeof ti.default === 'number') TARGET_CONF = ti.default;
272
+ }
273
+ } catch (_) { /* no target goal shown */ }
245
274
  PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
246
275
  C.custom_details = C.custom_details || {};
247
276
  C.profile_colors = C.profile_colors || {};
@@ -370,7 +399,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
370
399
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
371
400
  else el.textContent=msg||'Auto-save on';}
372
401
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
373
- custom_details:C.custom_details, profile_colors:C.profile_colors,
402
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
374
403
  plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
375
404
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
376
405
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -399,6 +428,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
399
428
  if(d.sig!==dataSig())return false;const by={};(d.plans||[]).forEach(p=>by[p.sheet]=p);
400
429
  if(d.custom_details)C.custom_details=d.custom_details;
401
430
  if(d.profile_colors)C.profile_colors=d.profile_colors;
431
+ if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
402
432
  C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;p.autofilled=true;}});
403
433
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
404
434
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
@@ -427,6 +457,46 @@ function redundantDups(){const g={};for(const m of P.members){(g[dupKey(m)]=g[du
427
457
  const out=[];for(const k in g){const grp=g[k];if(grp.length<2)continue;
428
458
  grp.sort((a,b)=>dupScore(b)-dupScore(a));for(let i=1;i<grp.length;i++)out.push(grp[i].id);} // keep [0], rest redundant
429
459
  return out;}
460
+ // --- merge collinear chords: same-profile, end-to-end, STRAIGHT beam runs → one member each.
461
+ // The skew read breaks a chord into collinear sub-segments at every rung; this rejoins each run.
462
+ // MIRROR of server/steel-merge.ts (the tested source of truth) — keep the two in lock-step.
463
+ const MRG_END=3, MRG_ANG=3*Math.PI/180, MRG_TOS=0.5;
464
+ function _mUnit(a,b){const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1;return [dx/L,dy/L];}
465
+ function _mElig(m){return (m.role||'beam')==='beam'&&Array.isArray(m.wp)&&m.wp.length>=2&&typeof m.profile==='string'&&m.profile.trim()!=='';}
466
+ function _mShare(a,b){for(const i of[0,1])for(const j of[0,1])if(len(a.wp[i],b.wp[j])<=MRG_END)return{aEnd:i,bEnd:j};return null;}
467
+ function _mStraight(a,b,s){const da=_mUnit(a.wp[0],a.wp[1]),db=_mUnit(b.wp[0],b.wp[1]);
468
+ if(Math.abs(da[0]*db[1]-da[1]*db[0])>Math.sin(MRG_ANG))return false; // not collinear
469
+ const sh=a.wp[s.aEnd],ao=a.wp[1-s.aEnd],bo=b.wp[1-s.bEnd];
470
+ return (ao[0]-sh[0])*(bo[0]-sh[0])+(ao[1]-sh[1])*(bo[1]-sh[1])<0;} // outer points on opposite sides
471
+ function _mElev(a,b,s){const ea=(a.ends&&a.ends[s.aEnd]&&a.ends[s.aEnd].tos!=null)?a.ends[s.aEnd].tos:null;
472
+ const eb=(b.ends&&b.ends[s.bEnd]&&b.ends[s.bEnd].tos!=null)?b.ends[s.bEnd].tos:null;
473
+ if(ea==null&&eb==null)return true;if(ea==null||eb==null)return false;return Math.abs(ea-eb)<=MRG_TOS;}
474
+ function _mAble(a,b){if((a.profile||'').toUpperCase()!==(b.profile||'').toUpperCase())return null;
475
+ const s=_mShare(a,b);if(!s)return null;return _mStraight(a,b,s)&&_mElev(a,b,s)?s:null;}
476
+ function _mAngle(wp){const dx=Math.abs(wp[1][0]-wp[0][0]),dy=Math.abs(wp[1][1]-wp[0][1]),t=Math.tan(MRG_ANG);
477
+ return dy<=dx*t?'H':dx<=dy*t?'V':'D';}
478
+ function mergeCollinearJS(members){
479
+ const idx=members.map((_,i)=>i).filter(i=>_mElig(members[i]));
480
+ const adj=new Map();for(const i of idx)adj.set(i,[]);
481
+ for(let p=0;p<idx.length;p++)for(let q=p+1;q<idx.length;q++){const i=idx[p],j=idx[q];const s=_mAble(members[i],members[j]);
482
+ if(s){adj.get(i).push({j,s});adj.get(j).push({j:i,s:{aEnd:s.bEnd,bEnd:s.aEnd}});}}
483
+ const seen=new Set(),runByAnchor=new Map(),merged=new Set();let mergedAway=0;
484
+ for(const start of idx){if(seen.has(start))continue;const comp=[],stack=[start];seen.add(start);
485
+ while(stack.length){const n=stack.pop();comp.push(n);for(const e of adj.get(n))if(!seen.has(e.j)){seen.add(e.j);stack.push(e.j);}}
486
+ if(comp.length<2)continue;const deg=n=>adj.get(n).length;
487
+ if(comp.some(n=>deg(n)>2))continue;const pe=comp.filter(n=>deg(n)===1);if(pe.length!==2)continue; // branch/loop → skip
488
+ const ordered=[];let prev=-1,cur=pe[0];
489
+ while(cur!==-1){ordered.push(cur);const nx=adj.get(cur).find(e=>e.j!==prev);prev=cur;cur=nx?nx.j:-1;}
490
+ const first=ordered[0],last=ordered[ordered.length-1];
491
+ const fo=1-adj.get(first).find(e=>e.j===ordered[1]).s.aEnd, lo=1-adj.get(last).find(e=>e.j===ordered[ordered.length-2]).s.aEnd;
492
+ const m=JSON.parse(JSON.stringify(members[first])),a=members[first].wp[fo],b=members[last].wp[lo];
493
+ m.wp=[[a[0],a[1]],[b[0],b[1]]];
494
+ if(Array.isArray(members[first].ends)&&Array.isArray(members[last].ends))m.ends=[JSON.parse(JSON.stringify(members[first].ends[fo])),JSON.parse(JSON.stringify(members[last].ends[lo]))];
495
+ m.angle=_mAngle(m.wp);m.mf=ordered.some(n=>members[n].mf===true);m.verified=ordered.every(n=>members[n].verified===true);
496
+ runByAnchor.set(Math.min(...ordered),m);mergedAway+=ordered.length-1;for(const n of ordered)merged.add(n);}
497
+ if(mergedAway===0)return{members,mergedAway:0,runs:0};
498
+ const out=[];for(let i=0;i<members.length;i++){if(runByAnchor.has(i))out.push(runByAnchor.get(i));else if(!merged.has(i))out.push(members[i]);}
499
+ return{members:out,mergedAway,runs:runByAnchor.size};}
430
500
  const svg=document.getElementById('svg');
431
501
  function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
432
502
  // --- zoom + pan ---
@@ -471,6 +541,7 @@ function render(){
471
541
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
472
542
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
473
543
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf();
544
+ if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
474
545
  }
475
546
  function updDup(){const n=redundantDups().length;
476
547
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
@@ -689,6 +760,10 @@ document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-
689
760
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
690
761
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
691
762
  if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
763
+ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members); // conservative; result is undoable (Ctrl+Z)
764
+ if(!r.mergedAway){toast('Nothing to merge');return;}
765
+ edit(()=>{P.members=r.members;selIds=new Set();});
766
+ toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
692
767
  document.getElementById('undoB').onclick=doUndo;
693
768
  document.getElementById('redoB').onclick=doRedo;
694
769
  addEventListener('keydown',e=>{
@@ -715,6 +790,46 @@ document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
715
790
  document.getElementById('zOut').onclick=()=>zoomCentered(zoom/1.25);
716
791
  document.getElementById('zFit').onclick=fitToWindow;
717
792
  document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
793
+ // --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
794
+ // fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
795
+ const view3dApi={
796
+ // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
797
+ fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');return j.scene;}, // endpoint shape: {ok, scene, skipped}
798
+ onSelect:(id,additive)=>{if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection (Phase B4)
799
+ getMembers:()=>P.members, // raw members (wp) for snap geometry
800
+ ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
801
+ defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
802
+ onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
803
+ onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
804
+ if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
805
+ else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
806
+ };
807
+ function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
808
+ view3d=on;
809
+ const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
810
+ t2.classList.toggle('on',!on);t2.setAttribute('aria-pressed',String(!on));
811
+ t3.classList.toggle('on',on);t3.setAttribute('aria-pressed',String(on));
812
+ document.getElementById('stage').style.display=on?'none':'';
813
+ document.getElementById('stage3d').style.display=on?'block':'none';
814
+ document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
815
+ }
816
+ async function setView(on){
817
+ if(on){
818
+ if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
819
+ try{
820
+ if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
821
+ applyViewState(true);
822
+ window.Steel3DView.show();
823
+ await window.Steel3DView.rebuild();
824
+ window.Steel3DView.setSelection(selIds);
825
+ }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
826
+ applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
827
+ toast('Could not open 3D view: '+((e&&e.message)||e));
828
+ }
829
+ }else{applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
830
+ }
831
+ document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
832
+ document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
718
833
  // Ctrl+wheel = zoom at cursor
719
834
  stage.addEventListener('wheel',e=>{if(e.ctrlKey){e.preventDefault();applyZoom(zoom*Math.exp(-e.deltaY*0.0015),e.clientX,e.clientY);}},{passive:false});
720
835
  // middle-button drag = pan
@@ -874,10 +989,22 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
874
989
  const sc=byMember.filter(m=>m.band!=='rfi');const n=sc.reduce((s,m)=>s+m.tons*BANDW[m.band],0),d=sc.reduce((s,m)=>s+m.tons,0);
875
990
  return {byMember,byDetail,byCategory:{beams,columns,details,connections:{score:null,note:'Not scored yet (reserved slot)',counts:_cnt0()}},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
876
991
  function bandColorForPct(p){return p==null?'var(--mut)':p>=95?BANDC.verified:p>=80?BANDC.high:p>=50?BANDC.med:BANDC.low;}
992
+ // Effective target: the user's per-read override (in the contract) wins; else the app's
993
+ // target_confidence input default (read in boot()); else none. Editing the override never touches
994
+ // the .flo, so it can't disarm the Run gate.
995
+ function effectiveTarget(){const t=C&&C.target_confidence;return typeof t==='number'?t:(TARGET_CONF!=null?TARGET_CONF:null);}
877
996
  function updConf(){const el=document.getElementById('confStat');if(!el)return;const s=scoreContractJS();
878
997
  if(!s.byMember.length){el.style.display='none';return;}el.style.display='';
879
- const b=document.getElementById('confPct');b.textContent=s.overall.score==null?'—':s.overall.score+'%';b.style.color=bandColorForPct(s.overall.score);
880
- el.title='Confidence '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
998
+ const sc=s.overall.score,col=bandColorForPct(sc),tgt=effectiveTarget();
999
+ const b=document.getElementById('confPct');b.textContent=sc==null?'—':sc+'%';b.style.color=col;
1000
+ // Goal suffix: "· target N%" (muted, static) + "(−G%)" gap in the band color, shown only while below target.
1001
+ const tg=document.getElementById('confTgt');
1002
+ if(tg){
1003
+ if(tgt==null||sc==null)tg.textContent='';
1004
+ else{const gap=tgt-sc;
1005
+ tg.innerHTML=' · <span style="color:var(--mut)">target '+tgt+'%</span>'+(gap>0?' <span style="color:'+col+'">(−'+gap+'%)</span>':'');}
1006
+ }
1007
+ 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.';}
881
1008
  // --- the report modal ---
882
1009
  let confBand='all',confCat='all',confExpand=null;
883
1010
  function openConf(){confExpand=null;renderConf();document.getElementById('confModal').style.display='flex';}
@@ -886,6 +1013,8 @@ function confOpen(){return document.getElementById('confModal').style.display===
886
1013
  function _bar(pct){return '<div class=cc-bar><div class=cc-fill style="width:'+(pct||0)+'%;background:'+bandColorForPct(pct)+';'+(pct==null?'opacity:0':'')+'"></div></div>';}
887
1014
  function _countsHtml(c){const out=['verified','high','med','low','rfi'].filter(k=>c[k]>0).map(k=>'<span style="color:'+BANDC[k]+'">'+BANDLAB[k][0]+' <b>'+c[k]+'</b></span>').join('');return out||'<span>—</span>';}
888
1015
  function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
1016
+ 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)
1017
+ ct.value=typeof C.target_confidence==='number'?C.target_confidence:'';ct.placeholder=TARGET_CONF!=null?String(TARGET_CONF):'70';}
889
1018
  const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' title="not scored yet"':'')+'>'+
890
1019
  '<div class=cc-label>'+label+'</div>'+
891
1020
  '<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
@@ -924,6 +1053,11 @@ function wireConf(){
924
1053
  document.querySelectorAll('#confBody tr.confrow').forEach(r=>r.onclick=e=>{if(e.target.closest('button'))return;const id=r.dataset.mid;confExpand=confExpand===id?null:id;renderConf();});
925
1054
  document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
926
1055
  document.getElementById('confStat').onclick=openConf;
1056
+ document.getElementById('confTarget').onchange=e=>{ // per-read target override → contract.target_confidence (never the .flo)
1057
+ const raw=String(e.target.value).trim();
1058
+ if(raw===''){delete C.target_confidence;} // cleared → fall back to the app default
1059
+ else{let v=Math.round(+raw);if(!isFinite(v))return;v=Math.max(0,Math.min(100,v));C.target_confidence=v;e.target.value=v;}
1060
+ scheduleSave();updConf();};
927
1061
  document.getElementById('confClose').onclick=closeConf;
928
1062
  document.getElementById('confBackdrop').onclick=closeConf;
929
1063
  // --- detail preview lightbox (click a card / an end's ⤢ to enlarge) ---