@floless/app 0.73.0 → 0.74.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.
@@ -31,6 +31,7 @@
31
31
  const $stepTabs = document.getElementById('ws-step-tabs');
32
32
  const $projMenu = document.getElementById('ws-proj-menu');
33
33
  const $wsExports = document.getElementById('ws-exports');
34
+ const $wsHistory = document.getElementById('ws-history');
34
35
  const frames = {
35
36
  model: document.getElementById('ws-frame-model'),
36
37
  drawings: document.getElementById('ws-frame-drawings'),
@@ -201,6 +202,11 @@
201
202
  const showExports = s === 'exports';
202
203
  $wsExports.hidden = !showExports;
203
204
  if (showExports) renderExports();
205
+ // History is likewise a shell-rendered pane: the project's version ledger.
206
+ const showHistory = s === 'history';
207
+ $wsHistory.hidden = !showHistory;
208
+ if (showHistory) renderHistory();
209
+ else closeRollbackConfirm(); // don't leave a rollback popover open behind a step switch
204
210
  }
205
211
  $stepTabs.addEventListener('click', (e) => {
206
212
  const b = e.target.closest('button[data-step]');
@@ -227,6 +233,7 @@
227
233
  if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
228
234
  showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
229
235
  if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
236
+ if (!$wsHistory.hidden) renderHistory(); // a new version was recorded — reflect it + clear the banner
230
237
  } catch (err) {
231
238
  showToast('Approve failed: ' + (err && err.message || err), 'warn');
232
239
  } finally { btn.disabled = false; btn.textContent = prev; }
@@ -360,6 +367,138 @@
360
367
  if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
361
368
  });
362
369
 
370
+ // ── History step ────────────────────────────────────────────────────────────────
371
+ // The project's version ledger. A version = a commit (Approve = Model gate, Rollback = unsigned).
372
+ // The draft contract is the working tree; an unapproved draft shows the working-copy banner.
373
+ const histWhen = (iso) => {
374
+ const d = new Date(iso);
375
+ return isNaN(d.getTime()) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
376
+ };
377
+ // Avatar: the human is "You" (neutral fill); an AI author (future rebake) gets the accent fill —
378
+ // accent already means "the AI acted" elsewhere in the baseline, so it's a free scan signal.
379
+ const avatarFor = (author) =>
380
+ author === 'You'
381
+ ? '<span class="avatar you">YOU</span>'
382
+ : `<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>';
387
+
388
+ let versions = []; // last-rendered ledger (newest first)
389
+
390
+ function versionRowHtml(v) {
391
+ // Rolling back to the version you're already on is a no-op — omit the button (blank cell), never disable it.
392
+ const action = v.current
393
+ ? ''
394
+ : `<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' : ''}">` +
396
+ `<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
397
+ `<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
398
+ `<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
399
+ `<td>${gateBadge(v.gate)}</td>` +
400
+ `<td class="change">${escapeHtml(v.message)}</td>` +
401
+ `<td>${action}</td></tr>`;
402
+ }
403
+
404
+ async function renderHistory() {
405
+ if (!current) return;
406
+ const id = current.id;
407
+ $wsHistory.innerHTML = '<div class="hist-note">Loading history…</div>';
408
+ let rows = [];
409
+ let approvedAt;
410
+ try {
411
+ const r = await api(`/api/projects/${encodeURIComponent(id)}/versions`);
412
+ rows = Array.isArray(r.versions) ? r.versions : [];
413
+ approvedAt = r.approvedAt || undefined;
414
+ current.approvedAt = approvedAt; // re-sync — an in-editor autosave clears the sign-off server-side
415
+ } catch (err) {
416
+ showToast('Couldn’t load history: ' + ((err && err.message) || err), 'warn');
417
+ if (current && current.id === id && !$wsHistory.hidden) {
418
+ $wsHistory.innerHTML = '<div class="hist-empty">History unavailable — try again.</div>';
419
+ }
420
+ return;
421
+ }
422
+ if (!current || current.id !== id || $wsHistory.hidden) return; // switched away mid-await
423
+ versions = rows;
424
+ if (!rows.length) {
425
+ // Empty = an inline-action banner (never a bare header row that reads as broken/loading).
426
+ $wsHistory.innerHTML =
427
+ '<div class="hist-empty">No versions yet — <button type="button" id="hist-approve">Approve the model</button> ' +
428
+ 'to create <b>v1</b> and start the ledger.</div>';
429
+ return;
430
+ }
431
+ // Working copy = the draft; when it isn't signed off (approvedAt cleared by an edit or a
432
+ // rollback) surface it honestly ABOVE the table (not as a fake row) and name the Exports lock.
433
+ 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>'
436
+ : '';
437
+ $wsHistory.innerHTML =
438
+ '<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>' +
439
+ working +
440
+ // Version/Gate/action are fixed-narrow; When/By auto-size to their nowrap content (fixing them
441
+ // wider than the content wastes room and needlessly wraps Change). Change flexes to the rest.
442
+ '<table class="hist"><thead><tr>' +
443
+ '<th style="width:44px">Version</th><th>When</th><th>By</th>' +
444
+ '<th style="width:80px">Gate</th><th style="width:99%">Change</th><th style="width:92px"></th>' +
445
+ '</tr></thead><tbody>' + rows.map(versionRowHtml).join('') + '</tbody></table>';
446
+ }
447
+
448
+ // Rollback confirm — a styled anchored popover (never a native dialog); names the Exports re-lock.
449
+ function onDocDownForConfirm(e) {
450
+ if (!e.target.closest('.hist-confirm') && !e.target.closest('[data-rollback]')) closeRollbackConfirm();
451
+ }
452
+ function closeRollbackConfirm() {
453
+ const pop = $wsHistory.querySelector('.hist-confirm');
454
+ if (pop) pop.remove();
455
+ document.removeEventListener('mousedown', onDocDownForConfirm, true);
456
+ }
457
+ function showRollbackConfirm(btn, n) {
458
+ closeRollbackConfirm();
459
+ const pop = document.createElement('div');
460
+ pop.className = 'hist-confirm';
461
+ pop.innerHTML =
462
+ `<div class="hc-title">Restore v${escapeHtml(String(n))} as a new version?</div>` +
463
+ `<div class="hc-body">History isn’t erased — this adds a new version copied from v${escapeHtml(String(n))}. ` +
464
+ `Exports re-lock until you Approve again.</div>` +
465
+ `<div class="hc-act"><button type="button" class="hc-cancel">Cancel</button>` +
466
+ `<button type="button" class="hc-go" data-hc-go="${escapeAttr(String(n))}">↺ Rollback to v${escapeHtml(String(n))}</button></div>`;
467
+ $wsHistory.appendChild(pop);
468
+ const pr = $wsHistory.getBoundingClientRect();
469
+ const br = btn.getBoundingClientRect();
470
+ pop.style.top = br.bottom - pr.top + $wsHistory.scrollTop + 6 + 'px';
471
+ pop.style.left = Math.max(8, Math.min(br.right - pr.left - pop.offsetWidth, $wsHistory.clientWidth - pop.offsetWidth - 8)) + 'px';
472
+ document.addEventListener('mousedown', onDocDownForConfirm, true);
473
+ }
474
+
475
+ async function rollbackTo(n) {
476
+ if (!current) return;
477
+ const proj = current;
478
+ closeRollbackConfirm();
479
+ try {
480
+ await api(`/api/projects/${encodeURIComponent(proj.id)}/versions/${encodeURIComponent(n)}/rollback`, { method: 'POST' });
481
+ proj.approvedAt = undefined; // rollback re-locks exports (the server cleared the sign-off)
482
+ showToast(`Restored v${n} as a new version — Exports re-locked; Approve to re-enable`, 'ok');
483
+ // Reload the editor iframe so it shows the restored geometry (it fetched the contract on load).
484
+ frames.model.dataset.loaded = '';
485
+ frames.model.src = 'about:blank';
486
+ if (!frames.model.hidden) { frames.model.src = frames.model.dataset.want; frames.model.dataset.loaded = '1'; }
487
+ renderHistory();
488
+ } catch (err) {
489
+ showToast('Rollback failed: ' + ((err && err.message) || err), 'warn');
490
+ }
491
+ }
492
+
493
+ $wsHistory.addEventListener('click', (e) => {
494
+ if (e.target.closest('#hist-approve')) { approveProject(); return; }
495
+ if (e.target.closest('.hc-cancel')) { closeRollbackConfirm(); return; }
496
+ const go = e.target.closest('[data-hc-go]');
497
+ if (go) { rollbackTo(Number(go.dataset.hcGo)); return; }
498
+ const roll = e.target.closest('[data-rollback]');
499
+ if (roll) { showRollbackConfirm(roll, Number(roll.dataset.rollback)); return; }
500
+ });
501
+
363
502
  // ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
364
503
  let menuFor = null;
365
504
  function menuItem(action, label) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.73.0",
3
+ "version": "0.74.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": {