@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.
@@ -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;
@@ -206,7 +214,7 @@
206
214
  const showHistory = s === 'history';
207
215
  $wsHistory.hidden = !showHistory;
208
216
  if (showHistory) renderHistory();
209
- 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
210
218
  }
211
219
  $stepTabs.addEventListener('click', (e) => {
212
220
  const b = e.target.closest('button[data-step]');
@@ -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
 
@@ -392,18 +575,31 @@
392
575
  const action = v.current
393
576
  ? ''
394
577
  : `<button type="button" class="btn-mini" data-rollback="${escapeAttr(String(v.n))}" data-tip="Restore this version as a new version">↺ Rollback</button>`;
395
- 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>` +
396
586
  `<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
397
587
  `<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
398
588
  `<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
399
- `<td>${gateBadge(v.gate)}</td>` +
589
+ `<td>${gateBadge(v.gate, v.kind)}</td>` +
400
590
  `<td class="change">${escapeHtml(v.message)}</td>` +
401
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;
402
597
  }
403
598
 
404
599
  async function renderHistory() {
405
600
  if (!current) return;
406
601
  const id = current.id;
602
+ closeBaseMenu(); // this re-render replaces the History HTML — drop any open base-picker's listener first
407
603
  $wsHistory.innerHTML = '<div class="hist-note">Loading history…</div>';
408
604
  let rows = [];
409
605
  let approvedAt;
@@ -430,9 +626,13 @@
430
626
  }
431
627
  // Working copy = the draft; when it isn't signed off (approvedAt cleared by an edit or a
432
628
  // rollback) surface it honestly ABOVE the table (not as a fake row) and name the Exports lock.
629
+ const headKind = rows.length && rows[0].current ? rows[0].kind : undefined;
433
630
  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>'
631
+ ? (headKind === 'revision-read'
632
+ ? '<div class="hist-working">● <b>The AI read a revised drawing set review it before signing.</b> ' +
633
+ '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>'
634
+ : '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
635
+ '<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>')
436
636
  : '';
437
637
  $wsHistory.innerHTML =
438
638
  '<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>' +
@@ -440,6 +640,7 @@
440
640
  // Version/Gate/action are fixed-narrow; When/By auto-size to their nowrap content (fixing them
441
641
  // wider than the content wastes room and needlessly wraps Change). Change flexes to the rest.
442
642
  '<table class="hist"><thead><tr>' +
643
+ '<th style="width:26px"></th>' +
443
644
  '<th style="width:44px">Version</th><th>When</th><th>By</th>' +
444
645
  '<th style="width:80px">Gate</th><th style="width:99%">Change</th><th style="width:92px"></th>' +
445
646
  '</tr></thead><tbody>' + rows.map(versionRowHtml).join('') + '</tbody></table>';
@@ -490,6 +691,211 @@
490
691
  }
491
692
  }
492
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
+
493
899
  $wsHistory.addEventListener('click', (e) => {
494
900
  if (e.target.closest('#hist-approve')) { approveProject(); return; }
495
901
  if (e.target.closest('.hc-cancel')) { closeRollbackConfirm(); return; }
@@ -497,6 +903,14 @@
497
903
  if (go) { rollbackTo(Number(go.dataset.hcGo)); return; }
498
904
  const roll = e.target.closest('[data-rollback]');
499
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; }
500
914
  });
501
915
 
502
916
  // ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
@@ -553,6 +967,6 @@
553
967
  loadProjects();
554
968
  });
555
969
 
556
- window.flolessWorkspaces = { setMode, refresh: loadProjects };
970
+ window.flolessWorkspaces = { setMode, refresh: loadProjects, findActive: wsFindActive, find: wsFind, clearFind: wsClearFind };
557
971
  applyMode(); // restore persisted mode immediately (matches panels.js applyView timing)
558
972
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.75.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": {