@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/floless-server.cjs +334 -209
- package/dist/web/app.css +62 -1
- package/dist/web/index.html +5 -0
- package/dist/web/workspaces.js +139 -0
- package/package.json +1 -1
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); }
|
package/dist/web/index.html
CHANGED
|
@@ -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">
|
package/dist/web/workspaces.js
CHANGED
|
@@ -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) {
|