@floless/app 0.76.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
@@ -3508,3 +3508,76 @@ table.hist tr.current td { background:var(--accent-soft); }
3508
3508
  .hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
3509
3509
  border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
3510
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/aware.js CHANGED
@@ -4660,7 +4660,7 @@
4660
4660
  ? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
4661
4661
  : '';
4662
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}`;
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
4664
  }
4665
4665
  return '';
4666
4666
  }
@@ -214,7 +214,7 @@
214
214
  const showHistory = s === 'history';
215
215
  $wsHistory.hidden = !showHistory;
216
216
  if (showHistory) renderHistory();
217
- else closeRollbackConfirm(); // don't leave a rollback popover open behind a step switch
217
+ else { closeRollbackConfirm(); closeBaseMenu(); } // don't leave a popover open behind a step switch
218
218
  }
219
219
  $stepTabs.addEventListener('click', (e) => {
220
220
  const b = e.target.closest('button[data-step]');
@@ -575,18 +575,31 @@
575
575
  const action = v.current
576
576
  ? ''
577
577
  : `<button type="button" class="btn-mini" data-rollback="${escapeAttr(String(v.n))}" data-tip="Restore this version as a new version">↺ Rollback</button>`;
578
- return `<tr class="${v.current ? 'current' : ''}">` +
578
+ // "What changed" disclosure — only for a version that HAS a predecessor (n>1). v1 is the baseline,
579
+ // so it gets NO toggle at all (an ABSENT control, never a disabled one).
580
+ const canDiff = Number(v.n) > 1;
581
+ const toggle = canDiff
582
+ ? `<button type="button" class="hd-toggle" data-diff="${escapeAttr(String(v.n))}" aria-expanded="false" data-tip="What changed vs the previous version"><span class="hd-caret">▸</span></button>`
583
+ : '';
584
+ const verRow = `<tr class="${v.current ? 'current' : ''}" data-ver="${escapeAttr(String(v.n))}">` +
585
+ `<td class="hd-cell">${toggle}</td>` +
579
586
  `<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
580
587
  `<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
581
588
  `<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
582
589
  `<td>${gateBadge(v.gate, v.kind)}</td>` +
583
590
  `<td class="change">${escapeHtml(v.message)}</td>` +
584
591
  `<td>${action}</td></tr>`;
592
+ // The disclosure row is always emitted (hidden) so toggling is a pure show/hide + lazy fetch.
593
+ const diffRow = canDiff
594
+ ? `<tr class="hist-diff-row" data-diff-for="${escapeAttr(String(v.n))}" hidden><td colspan="7"><div class="hd-panel" data-panel="${escapeAttr(String(v.n))}"></div></td></tr>`
595
+ : '';
596
+ return verRow + diffRow;
585
597
  }
586
598
 
587
599
  async function renderHistory() {
588
600
  if (!current) return;
589
601
  const id = current.id;
602
+ closeBaseMenu(); // this re-render replaces the History HTML — drop any open base-picker's listener first
590
603
  $wsHistory.innerHTML = '<div class="hist-note">Loading history…</div>';
591
604
  let rows = [];
592
605
  let approvedAt;
@@ -627,6 +640,7 @@
627
640
  // Version/Gate/action are fixed-narrow; When/By auto-size to their nowrap content (fixing them
628
641
  // wider than the content wastes room and needlessly wraps Change). Change flexes to the rest.
629
642
  '<table class="hist"><thead><tr>' +
643
+ '<th style="width:26px"></th>' +
630
644
  '<th style="width:44px">Version</th><th>When</th><th>By</th>' +
631
645
  '<th style="width:80px">Gate</th><th style="width:99%">Change</th><th style="width:92px"></th>' +
632
646
  '</tr></thead><tbody>' + rows.map(versionRowHtml).join('') + '</tbody></table>';
@@ -677,6 +691,211 @@
677
691
  }
678
692
  }
679
693
 
694
+ // ── "What changed" semantic diff (Slice 5) ─────────────────────────────────
695
+ // The panel is DOM-built with textContent for every AI/drawing-supplied string (marks, profiles,
696
+ // connection summaries) — never innerHTML, per the file's security contract.
697
+ const DIFF_GROUPS = [
698
+ { key: 'added', label: 'Added' },
699
+ { key: 'removed', label: 'Removed' },
700
+ { key: 'resized', label: 'Resized' },
701
+ { key: 'moved', label: 'Moved' },
702
+ { key: 'material', label: 'Material' },
703
+ { key: 'connections', label: 'Connections' },
704
+ ];
705
+ const diffBase = new Map(); // "<projectId>\0<n>" → chosen baseline (undefined = compare to the previous version)
706
+ const baseKeyFor = (n) => (current && current.id ? current.id : '?') + '' + n; // project-scoped so a base pick can't bleed across projects sharing a version number
707
+ const el = (tag, cls, text) => {
708
+ const n = document.createElement(tag);
709
+ if (cls) n.className = cls;
710
+ if (text != null) n.textContent = text;
711
+ return n;
712
+ };
713
+
714
+ function diffMemberBasic(ref) {
715
+ const line = el('div', 'hd-item');
716
+ line.appendChild(el('span', 'hd-mark', ref.mark || ref.id));
717
+ if (ref.profile) line.appendChild(el('span', 'hd-prof', ref.profile));
718
+ if (ref.sheet) line.appendChild(el('span', 'hd-sheet', ref.sheet));
719
+ return line;
720
+ }
721
+ function diffMemberXform(ref, fromTxt, toTxt) {
722
+ const line = el('div', 'hd-item');
723
+ line.appendChild(el('span', 'hd-mark', ref.mark || ref.id));
724
+ const x = el('span', 'hd-xform');
725
+ x.appendChild(el('span', 'hd-prof', fromTxt || '—'));
726
+ x.appendChild(el('span', 'hd-arrow', '→'));
727
+ x.appendChild(el('span', 'hd-prof', toTxt || '—'));
728
+ line.appendChild(x);
729
+ if (ref.sheet) line.appendChild(el('span', 'hd-sheet', ref.sheet));
730
+ return line;
731
+ }
732
+ function diffMemberMoved(it) {
733
+ const line = el('div', 'hd-item');
734
+ line.appendChild(el('span', 'hd-mark', it.ref.mark || it.ref.id));
735
+ line.appendChild(el('span', 'hd-prof', `moved ~${it.feet} ft`));
736
+ if (it.scaleUnknown) line.appendChild(el('span', 'hd-qual', 'scale unknown'));
737
+ if (it.ref.sheet) line.appendChild(el('span', 'hd-sheet', it.ref.sheet));
738
+ return line;
739
+ }
740
+ function diffMemberConn(it) {
741
+ const line = el('div', 'hd-item');
742
+ line.appendChild(el('span', 'hd-mark', it.ref.mark || it.ref.id));
743
+ line.appendChild(el('span', 'hd-summary', it.summary || 'connection changed'));
744
+ if (it.ref.sheet) line.appendChild(el('span', 'hd-sheet', it.ref.sheet));
745
+ return line;
746
+ }
747
+ function diffRowFor(key, it) {
748
+ if (key === 'added' || key === 'removed') return diffMemberBasic(it);
749
+ if (key === 'resized' || key === 'material') return diffMemberXform(it.ref, it.from, it.to);
750
+ if (key === 'moved') return diffMemberMoved(it);
751
+ return diffMemberConn(it);
752
+ }
753
+ function diffGroup(g, items) {
754
+ if (!items || !items.length) return null;
755
+ const box = el('div', `hd-group ${g.key}`);
756
+ const head = el('div', 'hd-type');
757
+ head.appendChild(el('span', null, g.label.toUpperCase()));
758
+ head.appendChild(document.createTextNode(' '));
759
+ head.appendChild(el('span', 'hd-count', `(${items.length})`));
760
+ box.appendChild(head);
761
+ for (const it of items) box.appendChild(diffRowFor(g.key, it));
762
+ return box;
763
+ }
764
+ function diffNetHeader(t) {
765
+ const wrap = el('div', 'hd-net');
766
+ const net = t && typeof t.netTons === 'number' ? t.netTons : 0;
767
+ if (net === 0) wrap.classList.add('flat');
768
+ wrap.appendChild(el('span', 'hd-net-label', 'Net steel:'));
769
+ wrap.appendChild(el('span', 'hd-net-arrow', net > 0 ? '▲' : net < 0 ? '▼' : '•'));
770
+ wrap.appendChild(el('span', 'hd-net-val', `${net > 0 ? '+' : ''}${net} tons`));
771
+ return wrap;
772
+ }
773
+ // The engine's four honest signals — each names its CONSEQUENCE so a withheld member/connection is
774
+ // never silently absent from the panel. ALL render here (not just identity), above the numbers.
775
+ function diffCautions(w) {
776
+ if (!w) return [];
777
+ const mk = (strong, rest) => {
778
+ const box = el('div', 'hd-warn');
779
+ box.appendChild(el('span', 'hd-warn-ico', '⚠'));
780
+ const body = el('div');
781
+ if (strong) body.appendChild(el('b', null, strong));
782
+ if (rest) body.appendChild(document.createTextNode((strong ? ' ' : '') + rest));
783
+ box.appendChild(body);
784
+ return box;
785
+ };
786
+ const out = [];
787
+ if (w.identityNotPreserved) out.push(mk('These two reads don’t share member identity.', 'The comparison may be unreliable — the revised set looks re-read without carrying member labels forward.'));
788
+ if (w.duplicateKeys && w.duplicateKeys.length) out.push(mk(null, `${w.duplicateKeys.length} member${w.duplicateKeys.length > 1 ? 's were' : ' was'} left out — a duplicate id appears more than once on a sheet, so its changes can’t be attributed.`));
789
+ if (w.ambiguousJoints && w.ambiguousJoints.length) out.push(mk(null, 'Some connection changes couldn’t be attributed (a joint’s member id is shared across sheets) and were left out rather than guessed.'));
790
+ if (w.scaleUnknown && !w.identityNotPreserved) out.push(mk(null, 'A drawing’s scale is unknown, so the move distances shown are approximate.'));
791
+ return out;
792
+ }
793
+ function diffBasePicker(diff) {
794
+ const wrap = el('div', 'hd-basewrap');
795
+ wrap.appendChild(el('span', null, 'Compared to'));
796
+ const btn = el('button', 'hd-base');
797
+ btn.type = 'button';
798
+ btn.dataset.baseFor = String(diff.to);
799
+ btn.setAttribute('data-tip', 'Compare against an earlier version');
800
+ btn.appendChild(el('span', null, `v${diff.from}`));
801
+ btn.appendChild(el('span', 'hd-base-caret', '▾'));
802
+ wrap.appendChild(btn);
803
+ return wrap;
804
+ }
805
+ function renderDiffPanel(panel, diff) {
806
+ panel.textContent = '';
807
+ panel.appendChild(diffBasePicker(diff));
808
+ for (const c of diffCautions(diff.warnings)) panel.appendChild(c); // above the numbers — context for reading them
809
+ panel.appendChild(diffNetHeader(diff.tonnage));
810
+ let any = false;
811
+ for (const g of DIFF_GROUPS) {
812
+ const grp = diffGroup(g, diff[g.key]);
813
+ if (grp) { panel.appendChild(grp); any = true; }
814
+ }
815
+ if (!any) {
816
+ panel.appendChild(el('div', 'hd-empty', `No changes detected${diff.unchanged ? ` — ${diff.unchanged} members unchanged` : ''}.`));
817
+ } else if (diff.unchanged) {
818
+ panel.appendChild(el('div', 'hd-tail', `${diff.unchanged} members unchanged`));
819
+ }
820
+ }
821
+ async function loadDiff(n, base) {
822
+ const proj = current;
823
+ if (!proj) return;
824
+ const panel = $wsHistory.querySelector(`.hd-panel[data-panel="${CSS.escape(String(n))}"]`);
825
+ if (!panel) return;
826
+ const stamp = `${proj.id}:${n}:${base ?? ''}`; // guard against a stale response painting the wrong project/version/base
827
+ panel.dataset.stamp = stamp;
828
+ panel.textContent = '';
829
+ panel.appendChild(el('div', 'hd-loading', 'Comparing…'));
830
+ let diff;
831
+ try {
832
+ const q = base != null ? `?base=${encodeURIComponent(base)}` : '';
833
+ diff = (await api(`/api/projects/${encodeURIComponent(proj.id)}/versions/${encodeURIComponent(n)}/diff${q}`)).diff;
834
+ } catch (err) {
835
+ if (current && current.id === proj.id && panel.dataset.stamp === stamp) {
836
+ panel.textContent = '';
837
+ const box = el('div', 'hd-err');
838
+ box.appendChild(document.createTextNode('Couldn’t load the comparison' + (err && err.message ? ` — ${err.message}` : '') + ' — '));
839
+ const retry = el('button', null, 'try again');
840
+ retry.type = 'button';
841
+ retry.dataset.diffRetry = String(n);
842
+ box.appendChild(retry);
843
+ panel.appendChild(box);
844
+ }
845
+ return;
846
+ }
847
+ if (!current || current.id !== proj.id || panel.dataset.stamp !== stamp) return; // switched away / re-picked mid-await
848
+ renderDiffPanel(panel, diff);
849
+ }
850
+ function toggleDiff(btn) {
851
+ const n = Number(btn.dataset.diff);
852
+ const row = $wsHistory.querySelector(`tr.hist-diff-row[data-diff-for="${CSS.escape(String(n))}"]`);
853
+ if (!row) return;
854
+ const verRow = btn.closest('tr');
855
+ if (row.hasAttribute('hidden')) {
856
+ row.removeAttribute('hidden');
857
+ btn.setAttribute('aria-expanded', 'true');
858
+ if (verRow) verRow.classList.add('hd-open');
859
+ const panel = row.querySelector('.hd-panel');
860
+ if (panel && !panel.dataset.stamp) loadDiff(n, diffBase.get(baseKeyFor(n))); // lazy first load
861
+ } else {
862
+ row.setAttribute('hidden', '');
863
+ btn.setAttribute('aria-expanded', 'false');
864
+ if (verRow) verRow.classList.remove('hd-open');
865
+ }
866
+ }
867
+ // Baseline picker popover (mirrors the rollback-confirm popover idiom; never a native <select>).
868
+ function onDocDownForBase(e) {
869
+ if (!e.target.closest('.hd-base-menu') && !e.target.closest('.hd-base')) closeBaseMenu();
870
+ }
871
+ function closeBaseMenu() {
872
+ const m = $wsHistory.querySelector('.hd-base-menu');
873
+ if (m) m.remove();
874
+ document.removeEventListener('mousedown', onDocDownForBase, true);
875
+ }
876
+ function showBaseMenu(btn) {
877
+ closeBaseMenu();
878
+ const to = Number(btn.dataset.baseFor);
879
+ const cur = diffBase.get(baseKeyFor(to)) ?? to - 1;
880
+ const menu = el('div', 'hd-base-menu');
881
+ for (const v of versions) { // module-level ledger (newest first); only earlier versions are valid bases
882
+ if (Number(v.n) >= to) continue;
883
+ const opt = el('button', 'hd-base-opt' + (Number(v.n) === cur ? ' sel' : ''));
884
+ opt.type = 'button';
885
+ opt.dataset.baseOpt = String(v.n);
886
+ opt.dataset.baseTo = String(to);
887
+ opt.appendChild(el('span', null, `v${v.n}`));
888
+ opt.appendChild(el('span', 'hd-base-meta', histWhen(v.ts)));
889
+ menu.appendChild(opt);
890
+ }
891
+ $wsHistory.appendChild(menu);
892
+ const pr = $wsHistory.getBoundingClientRect();
893
+ const br = btn.getBoundingClientRect();
894
+ menu.style.top = br.bottom - pr.top + $wsHistory.scrollTop + 4 + 'px';
895
+ menu.style.left = Math.max(8, Math.min(br.left - pr.left, $wsHistory.clientWidth - menu.offsetWidth - 8)) + 'px';
896
+ document.addEventListener('mousedown', onDocDownForBase, true);
897
+ }
898
+
680
899
  $wsHistory.addEventListener('click', (e) => {
681
900
  if (e.target.closest('#hist-approve')) { approveProject(); return; }
682
901
  if (e.target.closest('.hc-cancel')) { closeRollbackConfirm(); return; }
@@ -684,6 +903,14 @@
684
903
  if (go) { rollbackTo(Number(go.dataset.hcGo)); return; }
685
904
  const roll = e.target.closest('[data-rollback]');
686
905
  if (roll) { showRollbackConfirm(roll, Number(roll.dataset.rollback)); return; }
906
+ const tog = e.target.closest('.hd-toggle');
907
+ if (tog) { toggleDiff(tog); return; }
908
+ const opt = e.target.closest('[data-base-opt]');
909
+ if (opt) { const to = Number(opt.dataset.baseTo), b = Number(opt.dataset.baseOpt); diffBase.set(baseKeyFor(to), b); closeBaseMenu(); loadDiff(to, b); return; }
910
+ const baseBtn = e.target.closest('.hd-base');
911
+ if (baseBtn) { showBaseMenu(baseBtn); return; }
912
+ const dr = e.target.closest('[data-diff-retry]');
913
+ if (dr) { const n = Number(dr.dataset.diffRetry); loadDiff(n, diffBase.get(baseKeyFor(n))); return; }
687
914
  });
688
915
 
689
916
  // ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.76.0",
3
+ "version": "0.77.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": {