@floless/app 0.73.1 → 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.
package/dist/web/app.css CHANGED
@@ -3312,7 +3312,7 @@ body {
3312
3312
  /* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
3313
3313
  /* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
3314
3314
  UA display:none — these guards keep hidden winning so nothing leaks across modes. */
3315
- .ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden] { display:none !important; }
3315
+ .ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden], .ws-history[hidden] { display:none !important; }
3316
3316
  .mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
3317
3317
  overflow:hidden; background:var(--surface-2); flex:none; }
3318
3318
  .mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
@@ -3428,3 +3428,64 @@ body {
3428
3428
  background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
3429
3429
  .ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
3430
3430
  .ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
3431
+
3432
+ /* ── Workspaces ▸ History step (shell-rendered pane; the project's version ledger). Ports the
3433
+ mockup's table.hist into app tokens. `position:relative` anchors the rollback confirm popover. */
3434
+ .ws-history { flex:1; min-height:0; position:relative; overflow-y:auto; padding:16px 20px;
3435
+ scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
3436
+ .ws-history::-webkit-scrollbar { width:10px; }
3437
+ .ws-history::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:5px; }
3438
+ .ws-history::-webkit-scrollbar-track { background:transparent; }
3439
+ .ws-history .hist-note { font-size:12px; color:var(--text-muted); margin:0 0 14px; }
3440
+ .ws-history .hist-note b { color:var(--text); font-weight:600; }
3441
+ /* Working-copy banner — sibling of .ws-exports-gate; NEUTRAL (accent-dim rail, never a danger
3442
+ color): "unapproved" is an absence-of-signature, not an error. Names the Exports-lock consequence. */
3443
+ .hist-working { margin:0 0 14px; padding:9px 13px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
3444
+ background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
3445
+ border-radius:6px; font-size:12px; color:var(--text-muted); }
3446
+ .hist-working b { color:var(--text); font-weight:600; }
3447
+ .hist-working button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
3448
+ .hist-working button:hover { color:var(--accent-bright); text-decoration:underline; }
3449
+ /* Empty state — an inline-action banner (never a bare header row, which reads as broken/loading). */
3450
+ .hist-empty { margin:6px 0 0; padding:11px 14px; background:var(--surface-2); border:1px solid var(--border);
3451
+ border-left:3px solid var(--accent-dim); border-radius:6px; font-size:12.5px; color:var(--text-muted); }
3452
+ .hist-empty b { color:var(--text); }
3453
+ .hist-empty button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
3454
+ .hist-empty button:hover { color:var(--accent-bright); text-decoration:underline; }
3455
+
3456
+ table.hist { width:100%; border-collapse:collapse; font-size:12.5px; }
3457
+ table.hist th { text-align:left; font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--text-dim);
3458
+ font-weight:600; padding:0 12px 8px; border-bottom:1px solid var(--border); white-space:nowrap; }
3459
+ table.hist td { padding:11px 12px; border-bottom:1px solid var(--border); color:var(--text); vertical-align:middle; }
3460
+ table.hist td.change { line-height:1.45; }
3461
+ table.hist tr:hover td { background:var(--surface-2); }
3462
+ table.hist tr.current td { background:var(--accent-soft); }
3463
+ .hist .ver { font-family:var(--mono); font-size:11.5px; color:var(--text-dim); }
3464
+ .hist .ver.cur { color:var(--accent-bright); }
3465
+ .hist .htime { color:var(--text-muted); white-space:nowrap; }
3466
+ .hist .hby { display:inline-flex; align-items:center; gap:7px; white-space:nowrap; }
3467
+ .hist .avatar { width:20px; height:20px; border-radius:50%; flex:none; display:inline-flex; align-items:center;
3468
+ justify-content:center; font-size:8.5px; font-weight:700; letter-spacing:.02em; }
3469
+ .hist .avatar.you { background:var(--surface-3); border:1px solid var(--border-strong); color:var(--text); }
3470
+ .hist .avatar.ai { background:var(--accent-dim); color:var(--text); }
3471
+ /* Gate badge — the signed sign-off, its own column so a long change message never buries it.
3472
+ "Model" (accent, signed) vs "unsigned" (dim/bordered — an absence of signature, not a warning). */
3473
+ .hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
3474
+ text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
3475
+ .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;
3477
+ padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
3478
+ .hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
3479
+
3480
+ /* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
3481
+ .hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
3482
+ border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); }
3483
+ .hist-confirm .hc-title { font-size:12.5px; font-weight:600; color:var(--text); margin-bottom:5px; }
3484
+ .hist-confirm .hc-body { font-size:11.5px; color:var(--text-muted); line-height:1.45; margin-bottom:11px; }
3485
+ .hist-confirm .hc-act { display:flex; gap:8px; justify-content:flex-end; }
3486
+ .hist-confirm .hc-go { background:var(--accent); border:1px solid var(--accent); color:white; font-weight:600;
3487
+ border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
3488
+ .hist-confirm .hc-go:hover { background:var(--accent-bright); border-color:var(--accent-bright); box-shadow:0 0 14px var(--accent-glow); }
3489
+ .hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
3490
+ border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
3491
+ .hist-confirm .hc-cancel:hover { color:var(--text); border-color:var(--accent); }
@@ -168,6 +168,7 @@
168
168
  <button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
169
169
  <button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
170
170
  <button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
171
+ <button type="button" data-step="history" role="tab" aria-selected="false">History</button>
171
172
  </div>
172
173
  <!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
173
174
  Same-origin like #contract-editor-frame (they call /api/contract directly). -->
@@ -176,6 +177,10 @@
176
177
  <!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
177
178
  contract. workspaces.js fills it on step-switch (renderExports). -->
178
179
  <div class="ws-exports" id="ws-exports" hidden></div>
180
+ <!-- History = a SHELL-rendered pane (not an iframe): the project's version ledger.
181
+ workspaces.js fills it on step-switch (renderHistory). Rollback is per-row (a header
182
+ ↺ Rollback verb was cut in design review — no selection context; per-row is unambiguous). -->
183
+ <div class="ws-history" id="ws-history" hidden></div>
179
184
  </div>
180
185
  <div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
181
186
  <div class="fav-bar" id="fav-bar">
@@ -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.1",
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": {