@floless/app 0.75.0 → 0.76.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.
package/dist/web/app.css CHANGED
@@ -3344,6 +3344,8 @@ body {
3344
3344
  .app.mode-workspaces .notes-strip, .app.mode-workspaces .find-overlay,
3345
3345
  /* the ws surfaces carry their own headings — hide the canvas's "Canvas · transparency layer" label */
3346
3346
  .app.mode-workspaces .canvas > .panel-label { display:none !important; }
3347
+ /* …but Find in model reuses that overlay — un-hide it while open (Ctrl+F searches editor members). */
3348
+ .app.mode-workspaces .find-overlay.show { display: flex !important; }
3347
3349
  /* the editor owns center+right in a project — collapse the inspect column entirely */
3348
3350
  .app.mode-workspaces { --right-width:0px !important; }
3349
3351
  .app.mode-workspaces .inspect { display:none; }
@@ -3473,9 +3475,26 @@ table.hist tr.current td { background:var(--accent-soft); }
3473
3475
  .hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
3474
3476
  text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
3475
3477
  .hist .gate-badge.model { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
3476
- .hist .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
3478
+ .hist .btn-mini, .ws-drawings-bar .btn-mini, .ws-revision-card .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
3477
3479
  padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
3478
- .hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
3480
+ .hist .btn-mini:hover, .ws-drawings-bar .btn-mini:hover, .ws-revision-card .btn-mini:hover { color:var(--text); border-color:var(--accent); }
3481
+
3482
+ /* ── Workspaces: revision re-read (Slice 4b) — attach band + pending/failed card ── */
3483
+ .ws-drawings-bar { flex:none; display:flex; align-items:center; gap:10px; padding:8px 14px; border-bottom:1px solid var(--border); background:var(--surface); }
3484
+ .ws-drawings-bar[hidden] { display:none !important; }
3485
+ .ws-drawings-bar-label { font-size:10px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.1em; }
3486
+ .ws-drawings-bar-sep { flex:1; }
3487
+ .ws-revision-card { flex:none; display:flex; align-items:center; gap:10px; padding:9px 14px; border-bottom:1px solid var(--border); background:var(--surface-2); font-size:12px; color:var(--text-muted); }
3488
+ .ws-revision-card[hidden] { display:none !important; }
3489
+ .ws-revision-card b { color:var(--text); font-weight:600; }
3490
+ .ws-revision-card .wrc-text { flex:1; min-width:0; }
3491
+ .ws-revision-card .wrc-spin { flex:none; width:14px; height:14px; border:2px solid var(--border-strong); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; }
3492
+ .ws-revision-card.state-failed { border-left:3px solid var(--err); }
3493
+ .ws-revision-card.state-failed .wrc-err-ico { color:var(--err); flex:none; }
3494
+ .ws-revision-card.state-failed .wrc-err-detail { color:var(--text-dim); }
3495
+ .ws-revision-card .wrc-actions { display:flex; gap:6px; flex:none; }
3496
+ @media (prefers-reduced-motion: reduce) { .ws-revision-card .wrc-spin { animation:none; } }
3497
+ .hist .gate-badge.ai-read { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
3479
3498
 
3480
3499
  /* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
3481
3500
  .hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
package/dist/web/app.js CHANGED
@@ -1094,6 +1094,10 @@ const $findInput = document.getElementById('find-input');
1094
1094
  const $findCount = document.getElementById('find-count');
1095
1095
 
1096
1096
  function openFind() {
1097
+ const ws = window.flolessWorkspaces && window.flolessWorkspaces.findActive();
1098
+ // In Workspaces mode there's nothing to find off the Model step — don't pop a dead overlay.
1099
+ if (document.getElementById('app').classList.contains('mode-workspaces') && !ws) return;
1100
+ $findInput.placeholder = ws ? 'Find in model…' : 'Find agent…';
1097
1101
  $findOverlay.classList.add('show');
1098
1102
  $findInput.value = '';
1099
1103
  $findCount.textContent = '';
@@ -1101,13 +1105,21 @@ function openFind() {
1101
1105
  }
1102
1106
  function closeFind() {
1103
1107
  $findOverlay.classList.remove('show');
1108
+ if (window.flolessWorkspaces && window.flolessWorkspaces.clearFind) window.flolessWorkspaces.clearFind();
1104
1109
  document.querySelectorAll('.agent-card').forEach(c => {
1105
1110
  c.classList.remove('find-dim', 'find-match');
1106
1111
  });
1107
1112
  }
1108
1113
 
1109
1114
  $findInput.addEventListener('input', () => {
1110
- const q = $findInput.value.toLowerCase().trim();
1115
+ const raw = $findInput.value;
1116
+ const q = raw.toLowerCase().trim();
1117
+ // Workspaces + Model step: search members in the embedded editor, not the canvas.
1118
+ if (window.flolessWorkspaces && window.flolessWorkspaces.findActive()) {
1119
+ const r = window.flolessWorkspaces.find(raw) || { count: 0 };
1120
+ $findCount.textContent = q ? `${r.count} match${r.count === 1 ? '' : 'es'}` : '';
1121
+ return;
1122
+ }
1111
1123
  let matches = 0;
1112
1124
  document.querySelectorAll('.agent-card').forEach(card => {
1113
1125
  if (!q) {
package/dist/web/aware.js CHANGED
@@ -4655,6 +4655,13 @@
4655
4655
  if (req.type === 'new-workflow') {
4656
4656
  return `Help me build a brand-new floless workflow from scratch, step by step, with your floless-app-new-workflow skill. Ask me what I want it to do, then before writing any node confirm the agents it needs are installed (offer to install a missing one from the catalogue, or to report a not-yet-existing agent as an idea). Author the .flo node by node, verifying each step is correct (plain-English descriptions, validate, compile, a real run), and finish by installing → Compile → ▶ Run so I can see it work and approve.`;
4657
4657
  }
4658
+ if (req.type === 'revision-read') {
4659
+ const snaps = req.snapshots && req.snapshots.length
4660
+ ? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
4661
+ : '';
4662
+ const note = req.instruction ? `\nMy note: ${req.instruction}` : '';
4663
+ return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract, then POST /api/projects/${req.project}/revision-read with { contract, message: "<plain-English what changed>", requestId: "${req.id}" }. The server derives the base version + source provenance from this queued request, records a "revision-read" version, and clears the request — do NOT DELETE the request yourself. If you can't read the set, POST /api/projects/${req.project}/revision-requests/${req.id}/fail with { error }.${snaps}${note}`;
4664
+ }
4658
4665
  return '';
4659
4666
  }
4660
4667
 
@@ -4665,6 +4672,7 @@
4665
4672
  tweak: 'floless-app-workflows',
4666
4673
  'ui-customize': 'floless-app-ui',
4667
4674
  rebake: 'floless-app-rebake',
4675
+ 'revision-read': 'floless-app-steel-takeoff',
4668
4676
  // The guided-tour ask is picked up by onboarding; the instruction body names the
4669
4677
  // per-app floless-app-<appId> skill for depth when one exists.
4670
4678
  guide: 'floless-app-onboarding',
@@ -4682,10 +4690,15 @@
4682
4690
  if (!body) return '';
4683
4691
  const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
4684
4692
  const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
4693
+ // A revision-read is cleared by its own /revision-read POST — a separate DELETE would 404. Every
4694
+ // other type is applied-then-DELETEd by the terminal AI.
4695
+ const clears = req.type === 'revision-read'
4696
+ ? `apply it via the route in the line below (that POST clears this request — do NOT DELETE it separately).`
4697
+ : `apply that request, then DELETE ${base}/api/requests/${req.id}.`;
4685
4698
  const marker =
4686
4699
  `[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
4687
4700
  `Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
4688
- `apply that request, then DELETE ${base}/api/requests/${req.id}. Don't run the line below verbatim.`;
4701
+ `${clears} Don't run the line below verbatim.`;
4689
4702
  return `${marker}\n${body}`;
4690
4703
  }
4691
4704
 
@@ -4764,8 +4777,8 @@
4764
4777
  return;
4765
4778
  }
4766
4779
  $list.innerHTML = pendingRequests.map((r) => {
4767
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
4768
- const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
4780
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'revision-read' ? 'revision' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
4781
+ const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' || r.type === 'revision-read' ? 'req-type req-type-tweak' : 'req-type';
4769
4782
  const target = r.type === 'tweak' && r.nodeId
4770
4783
  ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
4771
4784
  : r.type === 'rebake' && r.inputName
@@ -170,6 +170,15 @@
170
170
  <button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
171
171
  <button type="button" data-step="history" role="tab" aria-selected="false">History</button>
172
172
  </div>
173
+ <!-- Drawings step: a shell toolbar band + revision-read status card ABOVE the filter iframe
174
+ (chrome around it, never inside — the filter owns its own layout). Shown only on Drawings. -->
175
+ <div class="ws-drawings-bar" id="ws-drawings-bar" hidden>
176
+ <span class="ws-drawings-bar-label">Drawing set</span>
177
+ <span class="ws-drawings-bar-sep"></span>
178
+ <button type="button" id="ws-attach-revision" class="btn-mini" data-tip="Attach a revised drawing set — your terminal AI reads it into a new version">⤒ Attach revised drawings…</button>
179
+ <input type="file" id="ws-revision-file" accept=".pdf,image/*" multiple hidden>
180
+ </div>
181
+ <div class="ws-revision-card" id="ws-revision-card" hidden></div>
173
182
  <!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
174
183
  Same-origin like #contract-editor-frame (they call /api/contract directly). -->
175
184
  <iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
@@ -851,6 +851,7 @@ let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimen
851
851
  let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
852
852
  let sel3dDimIds=new Set(),dim3dAnchor=null; // selected 3D dimension ids (multi-select like parts; 3D view highlights them, Delete removes them)
853
853
  let selIds=new Set();
854
+ let findHits=new Set(); // Find-in-model highlight (Workspaces Ctrl+F) — kept SEPARATE from selIds so a search never clobbers/drops the user's real selection
854
855
  let undo=[], redo=[];
855
856
  const byId=id=>P.members.find(m=>m.id===id);
856
857
  const selArr=()=>P.members.filter(m=>selIds.has(m.id));
@@ -1420,8 +1421,8 @@ function render(){
1420
1421
  let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
1421
1422
  s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
1422
1423
  for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
1423
- for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
1424
- s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
1424
+ for(const m of P.members){const c=colorFor(m.profile);const sel=selIds.has(m.id);const fh=findHits.has(m.id);const on=sel||fh;const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
1425
+ s+=`<line class="member${m.rfi?' rfi':''}${sel?' sel':''}${fh?' find-hit':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
1425
1426
  {const hsel=selArr();if(hsel.length>=1){const HR=epR();for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · shown for every selected member · radius grows with zoom (epR) so it stays visible against the thick line
1426
1427
  if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
1427
1428
  s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
@@ -3036,16 +3037,36 @@ const view3dApi={
3036
3037
  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)
3037
3038
  onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
3038
3039
  onInsertPlace:(pick,pending)=>{
3039
- // Slice B: place an IMPORTED connection — bake a `custom` joint carrying its LOCAL mesh geometry at
3040
- // the picked point (joint.place); expandCustom re-expands it into the scene as one selectable unit.
3041
- if(pending&&pending.kind==='connection'&&pending.connection&&Array.isArray(pending.connection.geometry)){
3042
- const conn=pending.connection;
3043
- const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3044
- const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
3045
- if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
3046
- edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(conn.geometry.map((g,i)=>id+':'+(g.id||'m'+i)));});
3047
- toast('Connection '+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace');
3048
- return;
3040
+ if(pending&&pending.kind==='connection'&&pending.connection){
3041
+ const conn=pending.connection;const rc=conn.recipe;
3042
+ // Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
3043
+ // expandBasePlate re-derives it on that column from the fitted params (frame-independent scalars), so
3044
+ // it becomes a first-class parametric connection, not opaque mesh. Needs a column at the pick.
3045
+ const col=(rc&&rc.kind==='base-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
3046
+ if(col){
3047
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3048
+ const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},rc.params),source:'user'};
3049
+ pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
3050
+ // Applying a base plate to a column REPLACES any base plate already on it (a column has one base
3051
+ // plate) — else the two overlap and "edit on member" would target the older joint, not this import.
3052
+ const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
3053
+ edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
3054
+ toast((had?'Base plate on '+col.id+' replaced with imported “'+(conn.name||'connection')+'”':'Base plate “'+(conn.name||'imported')+'” applied to '+col.id)+' — edit its parameters on the member');
3055
+ return;
3056
+ }
3057
+ // Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
3058
+ // the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
3059
+ // column, land here (still faithful geometry) with a hint toward the editable path.
3060
+ if(Array.isArray(conn.geometry)&&conn.geometry.length){
3061
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3062
+ const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
3063
+ if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
3064
+ edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(conn.geometry.map((g,i)=>id+':'+(g.id||'m'+i)));});
3065
+ toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a column to apply it as an editable base plate')
3066
+ :('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
3067
+ return;
3068
+ }
3069
+ toast('That connection has no geometry to place');return;
3049
3070
  }
3050
3071
  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
3051
3072
  const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
@@ -3058,7 +3079,11 @@ const view3dApi={
3058
3079
  };
3059
3080
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
3060
3081
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
3061
- function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();panel();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile) + re-renders the inspector so a selection of just-created parts (e.g. a placed custom connection) resolves against the freshly-fetched partsById, not the pre-rebuild stale copy
3082
+ let pendingConnSel=null; // a just-baked connection whose parts exist only AFTER the next rebuild select the whole thing there (a recognized base-plate import, whose part ids aren't known ahead of expansion)
3083
+ function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{
3084
+ if(pendingConnSel&&window.Steel3DView.selectWholeConn){const c=pendingConnSel;pendingConnSel=null;window.Steel3DView.selectWholeConn(c);} // whole-connection select (envelope + "Parametric — editable" inspector), same as a 3D click
3085
+ else window.Steel3DView.setSelection(selIds);
3086
+ build3DLegend();panel();}).catch(()=>{pendingConnSel=null;});}} // rebuild also refreshes the legend (an edit may add/remove a profile) + re-renders the inspector so a selection of just-created parts (e.g. a placed custom connection) resolves against the freshly-fetched partsById, not the pre-rebuild stale copy
3062
3087
  // Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
3063
3088
  // detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
3064
3089
  // so the terminal AI knows whether to build a new detail or update a placed one, and where.
@@ -3066,12 +3091,17 @@ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];
3066
3091
  if(!view3d){toast('Switch to the 3D view to place a detail');return;}
3067
3092
  window.Steel3DView.setInsertMode(true,{name});
3068
3093
  toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
3069
- // Slice B: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry rides on the
3070
- // pending object; onInsertPlace bakes the custom joint where the user clicks). 3D view only.
3094
+ // Slice B/C: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry + optional
3095
+ // recognized recipe ride on the pending object; onInsertPlace bakes a base-plate recipe on a column, or
3096
+ // the custom mesh where the user clicks). 3D view only.
3071
3097
  function armConnectionInsert(connection){if(!connection||!Array.isArray(connection.geometry)||!connection.geometry.length){toast('That connection has no geometry to place');return;}
3072
3098
  if(!view3d){toast('Switch to the 3D view to place a connection');return;}
3073
3099
  window.Steel3DView.setInsertMode(true,{kind:'connection',connection});
3074
- toast('Click in the model to place “'+(connection.name||'connection')+'” — Esc to cancel');}
3100
+ const nm=connection.name||'connection';
3101
+ // Recognized base plate → tell the user to target a column (the editable path); else the generic place hint.
3102
+ const recognized=connection.recipe&&connection.recipe.kind==='base-plate';
3103
+ toast(recognized?('Click a column to place “'+nm+'” as an editable base plate — Esc to cancel')
3104
+ :('Click in the model to place “'+nm+'” — Esc to cancel'));}
3075
3105
  async function detailRequest(intent,place,note){
3076
3106
  // flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
3077
3107
  // debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
@@ -3767,13 +3797,40 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
3767
3797
  function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
3768
3798
  applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
3769
3799
  const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
3770
- // Frame the whole current selection (Tekla "Zoom selected"); falls back to fit-all when nothing's picked — mirrors the 3D view's frameSelection().
3771
- function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();return;}
3800
+ // ── Find in model (Workspaces Ctrl+F) ──────────────────────────────────────────
3801
+ // Match a member's id/mark + profile (case-insensitive substring) across ALL plans, switch to the
3802
+ // first plan with a hit, HIGHLIGHT those hits (via findHits — a set kept SEPARATE from selIds so a
3803
+ // same-plan search never touches the user's real selection; a cross-plan hit switches sheets via
3804
+ // setPlan, which resets selection like any plan change), and frame them. Returns {count,shown}
3805
+ // for the shell's find overlay. Called cross-iframe by workspaces.js via the same-origin
3806
+ // contentWindow, like flushContract(). clearFind() drops the find highlight only (leaves selection).
3807
+ function _findMatch(m, q){ return [m.id, m.mark, m.profile].filter(Boolean).join(' ').toLowerCase().includes(q); }
3808
+ function findMember(query){
3809
+ const q = String(query == null ? '' : query).toLowerCase().trim();
3810
+ if(!q){ clearFind(); return { count: 0, shown: 0 }; }
3811
+ let total = 0, firstPlan = -1, firstHits = [];
3812
+ (C.plans || []).forEach((pl, pi) => {
3813
+ const ids = (pl.members || []).filter((m) => _findMatch(m, q)).map((m) => m.id);
3814
+ total += ids.length;
3815
+ if(ids.length && firstPlan < 0){ firstPlan = pi; firstHits = ids; }
3816
+ });
3817
+ if(firstPlan < 0){ findHits = new Set(); render(); return { count: 0, shown: 0 }; }
3818
+ if(firstPlan !== C.active) setPlan(firstPlan);
3819
+ findHits = new Set(firstHits);
3820
+ render(); zoomToMembers(P.members.filter((m) => findHits.has(m.id)));
3821
+ return { count: total, shown: firstHits.length };
3822
+ }
3823
+ function clearFind(){ if(findHits.size){ findHits = new Set(); render(); } }
3824
+ window.findMember = findMember; window.clearFind = clearFind; // expose to the shell, like window.flushContract (top-level fns aren't reliably on window here)
3825
+ // Frame an arbitrary set of members (bbox → fit); falls back to fit-all when empty. Shared by
3826
+ // zoomToSelection (Tekla "Zoom selected") and Find-in-model — mirrors the 3D view's frameSelection().
3827
+ function zoomToMembers(arr){if(!arr.length){fitToWindow();return;}
3772
3828
  let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
3773
3829
  for(const m of arr)for(const p of m.wp){if(p[0]<x0)x0=p[0];if(p[0]>x1)x1=p[0];if(p[1]<y0)y0=p[1];if(p[1]>y1)y1=p[1];}
3774
3830
  const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
3775
3831
  applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
3776
3832
  const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
3833
+ function zoomToSelection(){zoomToMembers(selArr());}
3777
3834
  function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
3778
3835
  g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
3779
3836
  list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
@@ -4037,7 +4094,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
4037
4094
  scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
4038
4095
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
4039
4096
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
4040
- selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
4097
+ selIds=new Set();findHits=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null; // findHits resets per plan like selIds — a stale Find highlight must not bleed onto another sheet via a colliding member id
4041
4098
  dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
4042
4099
  if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
4043
4100
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
@@ -36,6 +36,10 @@
36
36
  model: document.getElementById('ws-frame-model'),
37
37
  drawings: document.getElementById('ws-frame-drawings'),
38
38
  };
39
+ const $drawingsBar = document.getElementById('ws-drawings-bar');
40
+ const $revisionCard = document.getElementById('ws-revision-card');
41
+ const $revisionFile = document.getElementById('ws-revision-file');
42
+ let currentStep = 'model';
39
43
 
40
44
  // The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
41
45
  // line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
@@ -171,6 +175,7 @@
171
175
  if (!p) { try { p = (await api('/api/projects')).projects.find((x) => x.id === id); } catch { /* fall through */ } }
172
176
  if (!p) { showToast('Project not found — it may have been archived.', 'warn'); return loadProjects(); }
173
177
  current = p;
178
+ lastRevState = 'none'; stopRevisionPoll(); // fresh project — drop any prior revision-poll state
174
179
  $projName.textContent = p.name;
175
180
  $crumbName.textContent = p.name;
176
181
  $status.textContent = p.app;
@@ -184,10 +189,11 @@
184
189
  setStep('model');
185
190
  applyMode();
186
191
  }
187
- function closeProject() { current = null; closeProjMenu(); applyMode(); }
192
+ function closeProject() { current = null; stopRevisionPoll(); lastRevState = 'none'; $revisionCard.hidden = true; closeProjMenu(); applyMode(); }
188
193
  document.getElementById('ws-back').addEventListener('click', closeProject);
189
194
 
190
195
  function setStep(s) {
196
+ currentStep = s;
191
197
  $stepTabs.querySelectorAll('button').forEach((b) => {
192
198
  const on = b.dataset.step === s;
193
199
  b.classList.toggle('active', on);
@@ -198,6 +204,8 @@
198
204
  f.hidden = !show;
199
205
  if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
200
206
  }
207
+ $drawingsBar.hidden = s !== 'drawings';
208
+ renderRevisionCard(); // reflect any pending/failed revision read (card shows only on the Drawings step)
201
209
  // Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
202
210
  const showExports = s === 'exports';
203
211
  $wsExports.hidden = !showExports;
@@ -240,6 +248,43 @@
240
248
  }
241
249
  document.getElementById('ws-approve').addEventListener('click', approveProject);
242
250
 
251
+ // ── Find in model bridge (app.js Ctrl+F delegates here in Workspaces mode) ──────
252
+ // Active only when a project is open on the Model step, the editor iframe is loaded and exposes
253
+ // findMember(), and there's no pending "AI updated — reload" banner (searching a stale in-memory
254
+ // model would mislead). Same-origin contentWindow call, like the Approve flush above.
255
+ function wsFindActive() {
256
+ if (!$app.classList.contains('mode-workspaces')) return false;
257
+ if (!current || frames.model.hidden) return false; // Model step must be showing
258
+ const w = frames.model.contentWindow;
259
+ if (!w || typeof w.findMember !== 'function') return false;
260
+ try { if (w.document.getElementById('aiUpdateBanner')) return false; } catch { return false; }
261
+ return true;
262
+ }
263
+ function wsFind(q) {
264
+ try { const w = frames.model.contentWindow; if (w && typeof w.findMember === 'function') return w.findMember(q); } catch { /* iframe reloaded between findActive() and here */ }
265
+ return { count: 0 };
266
+ }
267
+ function wsClearFind() {
268
+ const w = frames.model && frames.model.contentWindow;
269
+ if (w && typeof w.clearFind === 'function') { try { w.clearFind(); } catch { /* iframe gone */ } }
270
+ }
271
+ // While focus is inside the editor iframe (the usual case on the Model step), the parent's Ctrl+F
272
+ // handler never sees the key — so the shell forwards Cmd/Ctrl+F from the same-origin editor up to
273
+ // the global openFind. Re-wired on every iframe (re)load; each load is a fresh contentWindow, so
274
+ // the listeners don't stack.
275
+ frames.model.addEventListener('load', () => {
276
+ try {
277
+ frames.model.contentWindow.addEventListener('keydown', (e) => {
278
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
279
+ e.preventDefault();
280
+ if (typeof window.openFind === 'function') window.openFind();
281
+ }
282
+ });
283
+ } catch { /* not-yet-loaded / cross-origin */ }
284
+ });
285
+ // (find methods are exposed on the canonical window.flolessWorkspaces export below — a separate
286
+ // assignment here would be clobbered by that later `= { setMode, … }`.)
287
+
243
288
  // ── Exports step ──────────────────────────────────────────────────────────────
244
289
  const hhmm = (iso) => {
245
290
  const d = new Date(iso);
@@ -367,6 +412,141 @@
367
412
  if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
368
413
  });
369
414
 
415
+ // ── Revisions: attach a revised drawing set → queue a compose-time read (Slice 4b) ──
416
+ // The browser records intent + files and QUEUES the read; the terminal AI reads and commits a
417
+ // `revision-read` version. The pending/failed card is Drawings-scoped (proximity to the action; the
418
+ // footer Requests popover surfaces it globally). A self-contained poll resolves it — the shell has
419
+ // no SSE hook — and refreshes History on completion. Cards are DOM-built (textContent, never
420
+ // innerHTML) so an AI-supplied error string can never reach the DOM as markup.
421
+ let lastRevState = 'none';
422
+ let revBaseVersion = 0; // the ledger head captured at attach — success = the head advances past it
423
+ let revCardReq = null;
424
+ let revisionPoll = null;
425
+ function stopRevisionPoll() { if (revisionPoll) { clearInterval(revisionPoll); revisionPoll = null; } }
426
+ function startRevisionPoll() {
427
+ stopRevisionPoll();
428
+ revisionPoll = setInterval(() => { if (current) renderRevisionCard(); else stopRevisionPoll(); }, 3000);
429
+ }
430
+ const revEl = (tag, cls, text) => {
431
+ const e = document.createElement(tag);
432
+ if (cls) e.className = cls;
433
+ if (text != null) e.textContent = text;
434
+ return e;
435
+ };
436
+
437
+ function readFileAsSnapshot(file) {
438
+ return new Promise((resolve, reject) => {
439
+ const r = new FileReader();
440
+ r.onload = () => resolve({ name: file.name, dataUrl: String(r.result) });
441
+ r.onerror = () => reject(new Error('read failed'));
442
+ r.readAsDataURL(file);
443
+ });
444
+ }
445
+
446
+ async function attachRevision(files) {
447
+ const proj = current; // capture — the user may switch projects during the async file reads / POST
448
+ if (!proj || !files.length) return;
449
+ let snapshots;
450
+ try { snapshots = await Promise.all(files.map(readFileAsSnapshot)); }
451
+ catch { showToast('Could not read the selected files', 'warn'); return; }
452
+ if (current !== proj) return; // switched projects mid-read — abandon rather than queue against the wrong one
453
+ let res;
454
+ try {
455
+ res = await api(`/api/projects/${encodeURIComponent(proj.id)}/revision-requests`, {
456
+ method: 'POST', body: JSON.stringify({ appId: proj.app, snapshots }),
457
+ });
458
+ } catch (err) { showToast('Attach failed: ' + ((err && err.message) || err), 'warn'); return; }
459
+ const req = res && res.request;
460
+ // Copy the paste-ready instruction so the user can hand it to their terminal AI (best-effort).
461
+ if (req && bridge.markedInstruction && bridge.copyToClipboard) {
462
+ try { await bridge.copyToClipboard(bridge.markedInstruction(req)); } catch { /* clipboard blocked */ }
463
+ }
464
+ if (bridge.loadRequests) bridge.loadRequests().catch(() => {}); // refresh the footer Requests popover
465
+ if (current !== proj) return; // switched away while posting — don't drive the wrong project's card/poll
466
+ showToast(`Revised set queued — ${files.length} file${files.length > 1 ? 's' : ''} for your terminal AI`, 'ok');
467
+ revBaseVersion = (req && typeof req.baseVersion === 'number') ? req.baseVersion : 0;
468
+ lastRevState = 'pending';
469
+ startRevisionPoll();
470
+ renderRevisionCard();
471
+ }
472
+
473
+ document.getElementById('ws-attach-revision').addEventListener('click', () => $revisionFile.click());
474
+ $revisionFile.addEventListener('change', () => {
475
+ const files = [...($revisionFile.files || [])];
476
+ $revisionFile.value = ''; // clear so re-picking the same file re-fires change
477
+ attachRevision(files);
478
+ });
479
+
480
+ function buildPendingCard(req) {
481
+ const n = (req.sourceRefs && req.sourceRefs.length) || (req.snapshots && req.snapshots.length) || 1;
482
+ const spin = revEl('span', 'wrc-spin'); spin.setAttribute('aria-hidden', 'true');
483
+ const txt = revEl('span', 'wrc-text');
484
+ txt.appendChild(revEl('b', null, 'Reading the revised set…'));
485
+ txt.appendChild(document.createTextNode(` ${n} file${n > 1 ? 's' : ''} queued for your terminal AI — paste the copied instruction there, or say “apply my floless request”.`));
486
+ const copy = revEl('button', 'btn-mini', '⧉ Copy');
487
+ copy.type = 'button'; copy.dataset.revcopy = ''; copy.setAttribute('data-tip', 'Copy the instruction again');
488
+ return [spin, txt, copy];
489
+ }
490
+ function buildFailedCard(req) {
491
+ const ico = revEl('span', 'wrc-err-ico', '⚠'); ico.setAttribute('aria-hidden', 'true');
492
+ const txt = revEl('span', 'wrc-text');
493
+ txt.appendChild(revEl('b', null, 'The read failed.'));
494
+ txt.appendChild(document.createTextNode(' '));
495
+ txt.appendChild(revEl('span', 'wrc-err-detail', req.error || 'The read could not be completed.'));
496
+ txt.appendChild(document.createTextNode(' Attach the set again to retry.'));
497
+ const actions = revEl('span', 'wrc-actions');
498
+ const dismiss = revEl('button', 'btn-mini', 'Dismiss'); dismiss.type = 'button'; dismiss.dataset.revdismiss = '';
499
+ actions.appendChild(dismiss);
500
+ return [ico, txt, actions];
501
+ }
502
+
503
+ async function renderRevisionCard() {
504
+ if (!current) { $revisionCard.hidden = true; return; }
505
+ let reqs = [];
506
+ try { const b = await api('/api/requests'); reqs = Array.isArray(b) ? b : (b.requests || []); } catch { return; }
507
+ const mine = reqs.filter((r) => r && r.type === 'revision-read' && r.project === current.id);
508
+ const failed = mine.find((r) => r.status === 'failed');
509
+ const pending = mine.find((r) => r.status === 'pending');
510
+ const state = failed ? 'failed' : pending ? 'pending' : 'none';
511
+ // Was pending, now nothing in flight. Confirm a NEW version actually committed (the head advanced
512
+ // past the attach baseVersion) before celebrating — a bare request DELETE (Dismiss / footer-clear)
513
+ // must NOT show a false "complete" toast or re-lock exports.
514
+ if (lastRevState === 'pending' && state === 'none') {
515
+ let vr;
516
+ try { vr = await api(`/api/projects/${encodeURIComponent(current.id)}/versions`); }
517
+ catch { return; } // transient — leave lastRevState 'pending'; the poll retries next tick
518
+ const tip = (vr.versions || [])[0];
519
+ if (tip && tip.kind === 'revision-read' && tip.n > revBaseVersion) {
520
+ current.approvedAt = vr.approvedAt || undefined; // the AI read cleared the sign-off (exports re-lock)
521
+ if (!$wsHistory.hidden) renderHistory();
522
+ showToast('Revision read complete — a new version is on History', 'ok');
523
+ }
524
+ }
525
+ lastRevState = state;
526
+ if (state === 'none') { stopRevisionPoll(); $revisionCard.hidden = true; $revisionCard.replaceChildren(); return; }
527
+ if (state === 'failed') stopRevisionPoll();
528
+ else if (!revisionPoll) startRevisionPoll(); // a pending read seen on load → keep it fresh
529
+ if (currentStep !== 'drawings') { $revisionCard.hidden = true; return; } // card is Drawings-scoped
530
+ revCardReq = failed || pending;
531
+ $revisionCard.className = 'ws-revision-card ' + (state === 'failed' ? 'state-failed' : 'state-pending');
532
+ $revisionCard.replaceChildren(...(state === 'failed' ? buildFailedCard(failed) : buildPendingCard(pending)));
533
+ $revisionCard.hidden = false;
534
+ }
535
+
536
+ $revisionCard.addEventListener('click', async (e) => {
537
+ const req = revCardReq;
538
+ if (!req) return;
539
+ if (e.target.closest('[data-revcopy]') && bridge.markedInstruction && bridge.copyToClipboard) {
540
+ const ok = await bridge.copyToClipboard(bridge.markedInstruction(req));
541
+ showToast(ok ? 'Instruction copied' : 'Copy blocked — use the Requests panel', ok ? 'ok' : 'warn');
542
+ } else if (e.target.closest('[data-revdismiss]')) {
543
+ try { await api(`/api/requests/${encodeURIComponent(req.id)}`, { method: 'DELETE' }); } catch { /* already gone */ }
544
+ lastRevState = 'none';
545
+ if (bridge.loadRequests) bridge.loadRequests().catch(() => {});
546
+ renderRevisionCard();
547
+ }
548
+ });
549
+
370
550
  // ── History step ────────────────────────────────────────────────────────────────
371
551
  // The project's version ledger. A version = a commit (Approve = Model gate, Rollback = unsigned).
372
552
  // The draft contract is the working tree; an unapproved draft shows the working-copy banner.
@@ -380,10 +560,13 @@
380
560
  author === 'You'
381
561
  ? '<span class="avatar you">YOU</span>'
382
562
  : `<span class="avatar ai">${escapeHtml((author || '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || 'AI')}</span>`;
383
- const gateBadge = (gate) =>
384
- gate === 'model'
385
- ? '<span class="gate-badge model" data-tip="Model gate — the geometry was signed off">Model</span>'
386
- : '<span class="gate-badge" data-tip="Unsigned — a working-tree restore, not a sign-off">unsigned</span>';
563
+ const gateBadge = (gate, kind) => {
564
+ if (gate === 'model')
565
+ return '<span class="gate-badge model" data-tip="Model gate — the geometry was signed off">Model</span>';
566
+ if (kind === 'revision-read')
567
+ return '<span class="gate-badge ai-read" data-tip="AI read — the terminal AI composed this version from a revised drawing set; Approve to sign it off">AI read</span>';
568
+ return '<span class="gate-badge" data-tip="Unsigned — a working-tree restore, not a sign-off">unsigned</span>';
569
+ };
387
570
 
388
571
  let versions = []; // last-rendered ledger (newest first)
389
572
 
@@ -396,7 +579,7 @@
396
579
  `<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
397
580
  `<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
398
581
  `<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
399
- `<td>${gateBadge(v.gate)}</td>` +
582
+ `<td>${gateBadge(v.gate, v.kind)}</td>` +
400
583
  `<td class="change">${escapeHtml(v.message)}</td>` +
401
584
  `<td>${action}</td></tr>`;
402
585
  }
@@ -430,9 +613,13 @@
430
613
  }
431
614
  // Working copy = the draft; when it isn't signed off (approvedAt cleared by an edit or a
432
615
  // rollback) surface it honestly ABOVE the table (not as a fake row) and name the Exports lock.
616
+ const headKind = rows.length && rows[0].current ? rows[0].kind : undefined;
433
617
  const working = !approvedAt
434
- ? '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
435
- '<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>'
618
+ ? (headKind === 'revision-read'
619
+ ? '<div class="hist-working">● <b>The AI read a revised drawing set review it before signing.</b> ' +
620
+ 'Open <b>Model</b> to check the change, then <button type="button" id="hist-approve">Approve</button> to sign off. Exports stay locked until you do.</div>'
621
+ : '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
622
+ '<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>')
436
623
  : '';
437
624
  $wsHistory.innerHTML =
438
625
  '<div class="hist-note">Every <b>Approve</b> and <b>Rollback</b> records a version. Rollback is non-destructive — it restores a version as a <i>new</i> one.</div>' +
@@ -553,6 +740,6 @@
553
740
  loadProjects();
554
741
  });
555
742
 
556
- window.flolessWorkspaces = { setMode, refresh: loadProjects };
743
+ window.flolessWorkspaces = { setMode, refresh: loadProjects, findActive: wsFindActive, find: wsFind, clearFind: wsClearFind };
557
744
  applyMode(); // restore persisted mode immediately (matches panels.js applyView timing)
558
745
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.75.0",
3
+ "version": "0.76.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": {