@floless/app 0.75.0 → 0.77.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.
@@ -251,7 +251,7 @@
251
251
  "verified": { "type": "boolean", "description": "True = a human confirmed this joint." },
252
252
  "name": { "type": "string", "description": "custom only: display label carried from the source IFC (e.g. \"COLUMN C102\")." },
253
253
  "place": { "$ref": "#/$defs/point3", "description": "custom only: the world-mm point the connection's LOCAL `geometry` is translated by — its placement in the model." },
254
- "geometry": { "type": "array", "items": { "$ref": "#/$defs/meshPart" }, "description": "custom only: imported mesh parts (one per plate/bolt/weld) in the connection's LOCAL mm frame. Read once from IFC by AWARE's connection-reader; opaque tessellated geometry (no per-dimension edit)." }
254
+ "geometry": { "type": "array", "items": { "$ref": "#/$defs/meshPart" }, "description": "custom only: imported mesh parts (one per plate/bolt/weld) in the connection's LOCAL mm frame (re-anchored at import from the reader's WORLD-mm output — the LOCAL frame is floless's own placement transform). Read once from IFC by AWARE's connection-reader `extract`, whose output is the source of truth for this mesh shape (see #/$defs/meshPart); opaque tessellated geometry (no per-dimension edit)." }
255
255
  },
256
256
  "allOf": [
257
257
  { "if": { "properties": { "kind": { "enum": ["base-plate", "shear-plate"] } } }, "then": { "required": ["main"] } },
@@ -262,7 +262,7 @@
262
262
  "type": "object",
263
263
  "required": ["positions", "indices"],
264
264
  "additionalProperties": true,
265
- "description": "One tessellated mesh part of a 'custom' connection (a plate / bolt / weld), in the connection's LOCAL mm frame: flat x,y,z position triples + 0-based triangle indices — AWARE's generic kind:\"mesh\" scene primitive. Read from IFC by the connection-reader; never hand-authored.",
265
+ "description": "One tessellated mesh part of a 'custom' connection (a plate / bolt / weld), in the connection's LOCAL mm frame: flat x,y,z position triples + 0-based triangle indices — AWARE's generic kind:\"mesh\" scene primitive. Shape is AWARE-owned: the source of truth is aware-aeco's connection-reader `extract` output (manifest.yaml + commands/extract.md). floless only consumes it — never hand-authored, never re-defined here.",
266
266
  "properties": {
267
267
  "id": { "type": "string", "description": "Source part id (the IFC element GlobalId)." },
268
268
  "role": { "type": "string", "description": "Hardware role from the source IFC: plate | bolt | weld." },
@@ -412,6 +412,7 @@
412
412
  "properties": {
413
413
  "id": { "type": "string" },
414
414
  "profile": { "type": "string", "description": "AISC designation (e.g. W16X26) or an unresolved mark (e.g. MF). Empty string allowed for an unprofiled member." },
415
+ "material": { "type": "string", "description": "ASTM material grade, e.g. A992 / A572-50 / A500. Absent = derive the default from the section family (W→A992, HSS→A500, plate/angle→A572/A36). Populated by the reader from the drawing's general-notes grade table (Slice 5b); the History semantic diff surfaces a change to it as the Material facet." },
415
416
  "wp": {
416
417
  "type": "array",
417
418
  "items": { "$ref": "#/$defs/point2" },
@@ -307,6 +307,7 @@ Important optional fields:
307
307
  {
308
308
  "id": "m1",
309
309
  "profile": "W16X26",
310
+ "material": "A992",
310
311
  "wp": [[x0,y0],[x1,y1]],
311
312
  "angle": "H",
312
313
  "role": "beam",
@@ -348,6 +349,12 @@ Key field rules:
348
349
  default.
349
350
  - `rfi: true` means no AISC size resolved — excluded from the BOM. `mf: true` marks a
350
351
  moment-frame member (persistent; survives AISC resolution).
352
+ - **`material` (grade) — capture it (Slice 5b).** Read the drawing's **general-notes grade table**
353
+ (the "MATERIAL" / "STRUCTURAL STEEL" notes) and stamp each member's `material` from its section
354
+ family: wide-flange / WT → `A992`, HSS → `A500` (Gr. B or C per the notes), pipe → `A53`,
355
+ angles / channels / plates → `A36` or `A572-50` per the notes. A per-member callout on the drawing
356
+ overrides the family default. Capturing grade is what lets the Workspaces **History → "What
357
+ changed"** compare surface a **Material** change when a revision re-grades a member.
351
358
  - `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
352
359
  **machine-local** and **never committed**.
353
360
  - **Connection library (`connections[]`) + per-end refs.** A vendor-neutral top-level
@@ -501,6 +508,24 @@ contract carries the source path in `source.path` and an embedded raster in `ras
501
508
  are machine-local (stored under `~/.floless/contracts/steel-model.json` by the server and
502
509
  never committed). Clip title blocks from any embedded preview.
503
510
 
511
+ ### 9. Revision re-read — preserve member identity (Slice 5)
512
+
513
+ When the trigger is a **`revision-read`** request (a REVISED drawing set attached to an *existing*
514
+ project), the Workspaces **History → "What changed"** compare matches members between versions by
515
+ their `id`, scoped per `sheet`. So the re-read must keep identity stable:
516
+
517
+ 1. **Fetch the current contract first** — `GET /api/contract/steel-model?project=<projectId>` (the
518
+ project id is in the request body). That is the previous version's working tree.
519
+ 2. **Carry ids + sheets forward** — for every member that still exists in the revised set, reuse its
520
+ existing `id` and keep it on the same `sheet` label (e.g. `S-201` stays `S-201`). Mint a NEW id
521
+ only for a genuinely new member; simply drop the id of a member the revision deleted.
522
+ 3. Then compose the updated `steel.takeoff/v1` and `POST /api/projects/<projectId>/revision-read
523
+ { contract, message, requestId }` (that POST clears the request — do **not** DELETE it).
524
+
525
+ If you renumber everything, the diff shows a misleading "all removed + all added" and the app warns
526
+ **"identity not preserved."** Carrying ids forward is what makes *"beam B12 was upsized"* come out
527
+ exact instead of a churn of the whole model.
528
+
504
529
  ---
505
530
 
506
531
  ## Guardrails
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);
@@ -3489,3 +3508,76 @@ table.hist tr.current td { background:var(--accent-soft); }
3489
3508
  .hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
3490
3509
  border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
3491
3510
  .hist-confirm .hc-cancel:hover { color:var(--text); border-color:var(--accent); }
3511
+
3512
+ /* ── Workspaces ▸ History ▸ "What changed" semantic diff (Slice 5) ──────────────
3513
+ A per-version disclosure row (colspan) under table.hist. Uses only History tokens; group
3514
+ headers follow the same mono-uppercase idiom as table.hist th. No new fonts/colors/widgets. */
3515
+ .hist .hd-cell { width:26px; padding-left:12px; padding-right:0; }
3516
+ .hd-toggle { background:none; border:none; padding:2px; margin:0; cursor:pointer; color:var(--text-dim);
3517
+ display:inline-flex; align-items:center; border-radius:4px; line-height:1; }
3518
+ .hd-toggle:hover, .hd-toggle[aria-expanded="true"] { color:var(--text); background:var(--surface-2); }
3519
+ .hd-caret { display:inline-block; font-size:10px; transition:transform .18s ease-out; }
3520
+ .hd-toggle[aria-expanded="true"] .hd-caret { transform:rotate(90deg); }
3521
+ table.hist tr.hd-open td { background:var(--surface-2); }
3522
+ /* disclosure row: flat --surface (a step below --surface-2) so it reads as nested content, not a ledger row */
3523
+ table.hist tr.hist-diff-row > td { background:var(--surface); padding:0; }
3524
+ table.hist tr.hist-diff-row:hover > td { background:var(--surface); }
3525
+ tr.hist-diff-row[hidden] { display:none; }
3526
+ .hd-panel { padding:12px 16px 14px; font-size:12px; color:var(--text-muted); }
3527
+ /* net-steel header — QUIET by design (no green/red value judgment); the arrow glyph carries direction. */
3528
+ .hd-net { display:flex; align-items:center; gap:7px; padding-bottom:10px; margin-bottom:10px;
3529
+ border-bottom:1px solid var(--border); font-size:13px; }
3530
+ .hd-net-label { color:var(--text-muted); font-weight:600; }
3531
+ .hd-net-arrow { color:var(--text-dim); font-size:11px; }
3532
+ .hd-net-val { font-family:var(--mono); color:var(--text); font-weight:600; }
3533
+ .hd-net.flat .hd-net-val { color:var(--text-dim); }
3534
+ /* change-group blocks — header text color-coded via existing semantic tokens; rows plain (no per-row pills). */
3535
+ .hd-group { margin-bottom:10px; padding-left:9px; border-left:2px solid var(--border-strong); }
3536
+ .hd-group:last-child { margin-bottom:0; }
3537
+ .hd-type { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.16em;
3538
+ font-weight:600; color:var(--text-dim); margin:0 0 5px; }
3539
+ .hd-type .hd-count { opacity:.7; }
3540
+ .hd-group.added { border-left-color:var(--ok); } .hd-group.added .hd-type { color:var(--ok); }
3541
+ .hd-group.removed { border-left-color:var(--err); } .hd-group.removed .hd-type { color:var(--err); }
3542
+ .hd-group.resized { border-left-color:var(--accent); } .hd-group.resized .hd-type { color:var(--accent-bright); }
3543
+ .hd-group.moved { border-left-color:var(--accent); } .hd-group.moved .hd-type { color:var(--accent-bright); }
3544
+ .hd-group.material { border-left-color:var(--warn); } .hd-group.material .hd-type { color:var(--warn); }
3545
+ .hd-group.connections { border-left-color:var(--info); } .hd-group.connections .hd-type { color:var(--info); }
3546
+ .hd-item { line-height:1.5; padding:1px 0; color:var(--text-muted); }
3547
+ .hd-mark { font-family:var(--mono); color:var(--text); font-weight:600; }
3548
+ .hd-prof { font-family:var(--mono); color:var(--text-muted); margin-left:6px; }
3549
+ .hd-summary { color:var(--text-muted); margin-left:6px; }
3550
+ .hd-sheet { font-size:10px; color:var(--text-dim); margin-left:6px; }
3551
+ .hd-xform { margin-left:6px; }
3552
+ .hd-arrow { color:var(--text-dim); margin:0 5px; }
3553
+ .hd-qual { color:var(--text-dim); font-style:italic; margin-left:6px; }
3554
+ .hd-tail { color:var(--text-dim); font-size:11px; padding-top:8px; }
3555
+ /* caution banner — the .hist-working recipe in amber (warn); never dismissible (load-bearing context). */
3556
+ .hd-warn { margin:0 0 11px; padding:9px 12px; display:flex; gap:8px; align-items:flex-start;
3557
+ background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--warn);
3558
+ border-radius:6px; font-size:11.5px; color:var(--text-muted); }
3559
+ .hd-warn b { color:var(--text); font-weight:600; }
3560
+ .hd-warn-ico { color:var(--warn); flex:none; }
3561
+ /* empty / loading / error states — never a blank panel */
3562
+ .hd-empty { color:var(--text-dim); font-style:italic; text-align:center; padding:14px 0; }
3563
+ .hd-loading { color:var(--text-dim); padding:10px 0; }
3564
+ .hd-err { color:var(--text-muted); padding:8px 0; }
3565
+ .hd-err button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
3566
+ .hd-err button:hover { color:var(--accent-bright); text-decoration:underline; }
3567
+ /* baseline picker — inline "Compared to vX ▾" + a themed anchored popover (NEVER a native <select>). */
3568
+ .hd-basewrap { display:flex; align-items:center; gap:7px; margin-bottom:11px; font-size:11px; color:var(--text-dim); }
3569
+ .hd-base { background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted);
3570
+ border-radius:4px; padding:3px 8px; font:inherit; font-size:11px; cursor:pointer; display:inline-flex; align-items:center; gap:5px; }
3571
+ .hd-base:hover { color:var(--text); border-color:var(--accent); }
3572
+ .hd-base-caret { color:var(--text-dim); font-size:9px; }
3573
+ .hd-base-menu { position:absolute; z-index:20; min-width:150px; max-height:220px; overflow-y:auto;
3574
+ background:var(--surface-3); border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4);
3575
+ padding:4px; scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
3576
+ .hd-base-menu::-webkit-scrollbar { width:8px; }
3577
+ .hd-base-menu::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:4px; }
3578
+ .hd-base-menu::-webkit-scrollbar-track { background:transparent; }
3579
+ .hd-base-opt { display:flex; justify-content:space-between; gap:12px; width:100%; background:none; border:none;
3580
+ color:var(--text-muted); font:inherit; font-size:11.5px; text-align:left; padding:5px 8px; border-radius:5px; cursor:pointer; }
3581
+ .hd-base-opt:hover { background:var(--surface-2); color:var(--text); }
3582
+ .hd-base-opt.sel { color:var(--accent-bright); }
3583
+ .hd-base-opt .hd-base-meta { font-family:var(--mono); font-size:10px; color:var(--text-dim); }
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. First GET /api/contract/${req.appId}?project=${req.project} to read the CURRENT contract and PRESERVE each still-present member's id AND its sheet label (mint a NEW id only for a genuinely new member; drop the id of a member the revision deleted) so the version comparison matches members — do NOT renumber the whole model. 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