@floless/app 0.70.0 → 0.72.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 +411 -232
- package/dist/web/app.css +82 -0
- package/dist/web/aware.js +1 -0
- package/dist/web/index.html +44 -0
- package/dist/web/steel-3d-view.js +83 -6
- package/dist/web/steel-editor.html +103 -7
- package/dist/web/steel-filter.html +5 -2
- package/dist/web/workspaces.js +270 -0
- package/package.json +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* workspaces.js — the Workflows | Workspaces mode shell (slice 1).
|
|
3
|
+
* Owns: mode state, the landing project grid, the open-project view (Drawings |
|
|
4
|
+
* Model iframes), the project picker + lifecycle menu, and per-project Approve.
|
|
5
|
+
* Composes with app.js/panels.js by CLASS-GATING (CSS hides Workflows chrome under
|
|
6
|
+
* .app.mode-workspaces) — it never removes their DOM or unwires their handlers.
|
|
7
|
+
* Reuses app.js globals (escapeHtml, escapeAttr, showToast) and the flolessBridge
|
|
8
|
+
* seams (api, formModal, loadRequests) like panels.js does.
|
|
9
|
+
* SECURITY CONTRACT (same as panels.js): every innerHTML interpolation passes
|
|
10
|
+
* escapeHtml/escapeAttr — project names are user-authored; no unescaped string may
|
|
11
|
+
* ever reach the DOM. textContent wherever no markup is needed.
|
|
12
|
+
* See docs/superpowers/specs/2026-07-03-workspaces-two-mode-shell-design.md.
|
|
13
|
+
* ========================================================================== */
|
|
14
|
+
(() => {
|
|
15
|
+
const bridge = window.flolessBridge || {};
|
|
16
|
+
const api = bridge.api || (async (path, opts) => {
|
|
17
|
+
const res = await fetch(path, { headers: { 'content-type': 'application/json' }, ...opts });
|
|
18
|
+
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
19
|
+
if (!res.ok || body.ok === false) { const e = new Error(body.error || `HTTP ${res.status}`); e.body = body; throw e; }
|
|
20
|
+
return body;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const $app = document.getElementById('app');
|
|
24
|
+
const $switch = document.getElementById('mode-switch');
|
|
25
|
+
const $landing = document.getElementById('ws-landing');
|
|
26
|
+
const $project = document.getElementById('ws-project');
|
|
27
|
+
const $spine = document.getElementById('ws-spine');
|
|
28
|
+
const $projName = document.getElementById('ws-proj-name');
|
|
29
|
+
const $crumbName = document.getElementById('ws-crumb-name');
|
|
30
|
+
const $status = document.getElementById('ws-status');
|
|
31
|
+
const $stepTabs = document.getElementById('ws-step-tabs');
|
|
32
|
+
const $projMenu = document.getElementById('ws-proj-menu');
|
|
33
|
+
const frames = {
|
|
34
|
+
model: document.getElementById('ws-frame-model'),
|
|
35
|
+
drawings: document.getElementById('ws-frame-drawings'),
|
|
36
|
+
};
|
|
37
|
+
if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
|
|
38
|
+
|
|
39
|
+
// The picker menu is a `.menu` (display:none by default; `.menu.show` → display:block, which
|
|
40
|
+
// OVERRIDES the `hidden` attribute). So close must clear BOTH — same idiom as the ext-menu.
|
|
41
|
+
const closeProjMenu = () => { $projMenu.hidden = true; $projMenu.classList.remove('show'); };
|
|
42
|
+
|
|
43
|
+
const LS_MODE = 'floless:mode';
|
|
44
|
+
const WORKSPACE_APP = 'steel-model'; // slice 1: the one workspace app; a manifest flag generalizes this in slice 5
|
|
45
|
+
let mode = 'workflows';
|
|
46
|
+
try { if (localStorage.getItem(LS_MODE) === 'workspaces') mode = 'workspaces'; } catch { /* private mode */ }
|
|
47
|
+
let projects = [];
|
|
48
|
+
let current = null; // the open project (null = landing)
|
|
49
|
+
|
|
50
|
+
// ── mode ────────────────────────────────────────────────────────────────────
|
|
51
|
+
function applyMode() {
|
|
52
|
+
const ws = mode === 'workspaces';
|
|
53
|
+
$app.classList.toggle('mode-workspaces', ws);
|
|
54
|
+
$switch.querySelectorAll('button').forEach((b) => {
|
|
55
|
+
const on = b.dataset.mode === mode;
|
|
56
|
+
b.classList.toggle('active', on);
|
|
57
|
+
b.setAttribute('aria-selected', String(on));
|
|
58
|
+
});
|
|
59
|
+
$landing.hidden = !ws || !!current;
|
|
60
|
+
$project.hidden = !ws || !current;
|
|
61
|
+
$spine.hidden = !ws || !current;
|
|
62
|
+
if (!ws && !$projMenu.hidden) closeProjMenu();
|
|
63
|
+
if (ws && !current) loadProjects();
|
|
64
|
+
}
|
|
65
|
+
function setMode(m) {
|
|
66
|
+
mode = m === 'workspaces' ? 'workspaces' : 'workflows';
|
|
67
|
+
try { localStorage.setItem(LS_MODE, mode); } catch { /* private mode */ }
|
|
68
|
+
applyMode();
|
|
69
|
+
}
|
|
70
|
+
$switch.addEventListener('click', (e) => {
|
|
71
|
+
const b = e.target.closest('button[data-mode]');
|
|
72
|
+
if (b) setMode(b.dataset.mode);
|
|
73
|
+
});
|
|
74
|
+
document.addEventListener('keydown', (e) => {
|
|
75
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
|
76
|
+
const t = e.target;
|
|
77
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
78
|
+
if (e.key === '1') { e.preventDefault(); setMode('workflows'); }
|
|
79
|
+
else if (e.key === '2') { e.preventDefault(); setMode('workspaces'); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── landing ─────────────────────────────────────────────────────────────────
|
|
83
|
+
const relAgo = (iso) => {
|
|
84
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
85
|
+
if (isNaN(ms) || ms < 0) return '';
|
|
86
|
+
const min = Math.round(ms / 60000);
|
|
87
|
+
if (min < 1) return 'just now';
|
|
88
|
+
if (min < 60) return `${min} min ago`;
|
|
89
|
+
const h = Math.round(min / 60);
|
|
90
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async function loadProjects() {
|
|
94
|
+
// Surface a real failure — never present a server/network error as an empty workspace
|
|
95
|
+
// (the empty-state CTA would be a lie: "no projects yet" when the truth is "load failed").
|
|
96
|
+
try { ({ projects } = await api('/api/projects')); }
|
|
97
|
+
catch (err) { projects = []; showToast('Couldn’t load projects: ' + (err && err.message || err), 'warn'); }
|
|
98
|
+
renderLanding();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderLanding() {
|
|
102
|
+
let html = `<div class="ws-head"><h2>Workspaces</h2>` +
|
|
103
|
+
`<div class="ws-sub">Live projects — open a document to keep working. No Run here.</div></div>` +
|
|
104
|
+
`<div class="ws-grid">`;
|
|
105
|
+
for (const p of projects) {
|
|
106
|
+
html += `<div class="ws-card" role="button" tabindex="0" data-open="${escapeAttr(p.id)}">` +
|
|
107
|
+
`<span class="ws-thumb"><span class="ws-kind">${escapeHtml(p.app)}</span>▦</span>` +
|
|
108
|
+
`<span class="ws-body"><span><span class="ws-name">${escapeHtml(p.name)}</span>` +
|
|
109
|
+
`<span class="ws-meta">${escapeHtml(p.app)} · <span class="t">${escapeHtml(relAgo(p.updatedAt))}</span></span></span>` +
|
|
110
|
+
`<button type="button" class="ws-kebab" data-kebab="${escapeAttr(p.id)}" aria-label="Project actions" data-tip="Rename · Duplicate · Archive">⋯</button>` +
|
|
111
|
+
`</span></div>`;
|
|
112
|
+
}
|
|
113
|
+
// Slice-1 creation path: seed a project from the app's current takeoff. The
|
|
114
|
+
// compose-time "read a drawing set" flow is Slice 4 — this card is honest about that.
|
|
115
|
+
html += `<button type="button" class="ws-seed" id="ws-seed">` +
|
|
116
|
+
`<span class="plus">+</span><span>Import the current ${escapeHtml(WORKSPACE_APP)} takeoff as a project</span></button>`;
|
|
117
|
+
html += `</div>`;
|
|
118
|
+
if (!projects.length) html += `<div class="ws-empty-note">No projects yet — import your current takeoff above, or ask your terminal AI to read a drawing set.</div>`;
|
|
119
|
+
$landing.innerHTML = html;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
$landing.addEventListener('click', async (e) => {
|
|
123
|
+
const kebab = e.target.closest('[data-kebab]');
|
|
124
|
+
if (kebab) { e.stopPropagation(); openProjMenu(kebab.dataset.kebab, kebab); return; }
|
|
125
|
+
const card = e.target.closest('[data-open]');
|
|
126
|
+
if (card) { openProject(card.dataset.open); return; }
|
|
127
|
+
if (e.target.closest('#ws-seed')) createSeeded();
|
|
128
|
+
});
|
|
129
|
+
$landing.addEventListener('keydown', (e) => {
|
|
130
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
131
|
+
const card = e.target.closest('[data-open]');
|
|
132
|
+
if (card) { e.preventDefault(); openProject(card.dataset.open); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function promptName(title, label, value) {
|
|
136
|
+
if (!bridge.formModal) return null;
|
|
137
|
+
const res = await bridge.formModal({ title, fields: [{ name: 'name', label, value, placeholder: 'e.g. Westfield Retail — Ph2' }], okLabel: 'Save' });
|
|
138
|
+
return res && typeof res.name === 'string' && res.name.trim() ? res.name.trim() : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function createSeeded() {
|
|
142
|
+
const name = await promptName('New project', 'Name this project', '');
|
|
143
|
+
if (!name) return;
|
|
144
|
+
try {
|
|
145
|
+
const { project, seeded } = await api('/api/projects', { method: 'POST', body: JSON.stringify({ name, app: WORKSPACE_APP, seedFromApp: true }) });
|
|
146
|
+
// Tell the truth about the import: seeded=false means there was no current takeoff to copy
|
|
147
|
+
// (or it was unreadable) — the project is EMPTY, don't imply the takeoff came across.
|
|
148
|
+
if (seeded) showToast(`Project "${project.name}" created from the current ${WORKSPACE_APP} takeoff`, 'ok');
|
|
149
|
+
else showToast(`Project "${project.name}" created — empty (no current ${WORKSPACE_APP} takeoff to import)`, 'warn');
|
|
150
|
+
projects.unshift(project);
|
|
151
|
+
openProject(project.id);
|
|
152
|
+
} catch (err) { showToast('Couldn’t create the project: ' + (err && err.message || err), 'warn'); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── open project ────────────────────────────────────────────────────────────
|
|
156
|
+
async function openProject(id) {
|
|
157
|
+
let p = projects.find((x) => x.id === id);
|
|
158
|
+
if (!p) { try { p = (await api('/api/projects')).projects.find((x) => x.id === id); } catch { /* fall through */ } }
|
|
159
|
+
if (!p) { showToast('Project not found — it may have been archived.', 'warn'); return loadProjects(); }
|
|
160
|
+
current = p;
|
|
161
|
+
$projName.textContent = p.name;
|
|
162
|
+
$crumbName.textContent = p.name;
|
|
163
|
+
$status.textContent = p.app;
|
|
164
|
+
// Lazily src the frames once per project; switching steps only toggles hidden so the
|
|
165
|
+
// editor's 3D state survives a Drawings⇄Model round-trip.
|
|
166
|
+
const qs = `?app=${encodeURIComponent(p.app)}&project=${encodeURIComponent(p.id)}`;
|
|
167
|
+
frames.model.dataset.want = `/steel-editor.html${qs}`;
|
|
168
|
+
frames.drawings.dataset.want = `/steel-filter.html${qs}`;
|
|
169
|
+
frames.model.src = 'about:blank'; frames.drawings.src = 'about:blank'; // reset any previous project
|
|
170
|
+
frames.model.dataset.loaded = ''; frames.drawings.dataset.loaded = '';
|
|
171
|
+
setStep('model');
|
|
172
|
+
applyMode();
|
|
173
|
+
}
|
|
174
|
+
function closeProject() { current = null; closeProjMenu(); applyMode(); }
|
|
175
|
+
document.getElementById('ws-back').addEventListener('click', closeProject);
|
|
176
|
+
|
|
177
|
+
function setStep(s) {
|
|
178
|
+
$stepTabs.querySelectorAll('button').forEach((b) => {
|
|
179
|
+
const on = b.dataset.step === s;
|
|
180
|
+
b.classList.toggle('active', on);
|
|
181
|
+
b.setAttribute('aria-selected', String(on));
|
|
182
|
+
});
|
|
183
|
+
for (const [k, f] of Object.entries(frames)) {
|
|
184
|
+
const show = k === s;
|
|
185
|
+
f.hidden = !show;
|
|
186
|
+
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
$stepTabs.addEventListener('click', (e) => {
|
|
190
|
+
const b = e.target.closest('button[data-step]');
|
|
191
|
+
if (b) setStep(b.dataset.step);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Approve (per-project) ───────────────────────────────────────────────────
|
|
195
|
+
document.getElementById('ws-approve').addEventListener('click', async () => {
|
|
196
|
+
if (!current) return;
|
|
197
|
+
const btn = document.getElementById('ws-approve');
|
|
198
|
+
btn.disabled = true;
|
|
199
|
+
const prev = btn.textContent;
|
|
200
|
+
btn.textContent = '◆ Approving…';
|
|
201
|
+
try {
|
|
202
|
+
// Flush the editor's debounced autosave FIRST (same-origin iframe) so Approve bakes the
|
|
203
|
+
// LATEST edits, not a stale contract — a user can edit then hit Approve before the ~debounce
|
|
204
|
+
// fires. flushContract() PUTs the live contract and resolves; a save failure aborts the bake.
|
|
205
|
+
const win = frames.model && frames.model.contentWindow;
|
|
206
|
+
if (win && typeof win.flushContract === 'function') await win.flushContract();
|
|
207
|
+
await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
208
|
+
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
showToast('Approve failed: ' + (err && err.message || err), 'warn');
|
|
211
|
+
} finally { btn.disabled = false; btn.textContent = prev; }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|
|
215
|
+
let menuFor = null;
|
|
216
|
+
function menuItem(action, label) {
|
|
217
|
+
return `<button class="menu-item" data-act="${action}" role="menuitem"><span class="menu-label">${escapeHtml(label)}</span></button>`;
|
|
218
|
+
}
|
|
219
|
+
function openProjMenu(id, anchor) {
|
|
220
|
+
menuFor = id;
|
|
221
|
+
const others = projects.filter((p) => p.id !== id);
|
|
222
|
+
$projMenu.innerHTML =
|
|
223
|
+
(others.length ? others.map((p) => `<button class="menu-item" data-goto="${escapeAttr(p.id)}" role="menuitem"><span class="menu-label">Open: ${escapeHtml(p.name)}</span></button>`).join('') + '<div class="menu-divider"></div>' : '') +
|
|
224
|
+
menuItem('rename', 'Rename…') + menuItem('duplicate', 'Duplicate') +
|
|
225
|
+
'<div class="menu-divider"></div>' + menuItem('archive', 'Archive');
|
|
226
|
+
const r = anchor.getBoundingClientRect();
|
|
227
|
+
$projMenu.style.left = Math.round(Math.max(8, Math.min(r.left, window.innerWidth - 260))) + 'px';
|
|
228
|
+
$projMenu.style.top = Math.round(r.bottom + 6) + 'px';
|
|
229
|
+
$projMenu.hidden = false;
|
|
230
|
+
$projMenu.classList.add('show');
|
|
231
|
+
}
|
|
232
|
+
document.getElementById('ws-proj-trigger').addEventListener('click', (e) => {
|
|
233
|
+
if (!current) return;
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
if ($projMenu.hidden) openProjMenu(current.id, e.currentTarget); else closeProjMenu();
|
|
236
|
+
});
|
|
237
|
+
document.addEventListener('click', (e) => {
|
|
238
|
+
if ($projMenu.hidden) return;
|
|
239
|
+
if (!$projMenu.contains(e.target) && !e.target.closest('#ws-proj-trigger') && !e.target.closest('[data-kebab]')) closeProjMenu();
|
|
240
|
+
});
|
|
241
|
+
$projMenu.addEventListener('click', async (e) => {
|
|
242
|
+
const goto = e.target.closest('[data-goto]');
|
|
243
|
+
if (goto) { closeProjMenu(); return openProject(goto.dataset.goto); }
|
|
244
|
+
const act = e.target.closest('[data-act]');
|
|
245
|
+
if (!act || !menuFor) return;
|
|
246
|
+
closeProjMenu();
|
|
247
|
+
const id = menuFor;
|
|
248
|
+
const proj = projects.find((p) => p.id === id);
|
|
249
|
+
try {
|
|
250
|
+
if (act.dataset.act === 'rename') {
|
|
251
|
+
const name = await promptName('Rename project', 'New name', proj ? proj.name : '');
|
|
252
|
+
if (!name) return;
|
|
253
|
+
const { project } = await api(`/api/projects/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name }) });
|
|
254
|
+
if (current && current.id === id) { current = project; $projName.textContent = project.name; $crumbName.textContent = project.name; }
|
|
255
|
+
showToast('Renamed', 'ok');
|
|
256
|
+
} else if (act.dataset.act === 'duplicate') {
|
|
257
|
+
const { project } = await api(`/api/projects/${encodeURIComponent(id)}/duplicate`, { method: 'POST', body: '{}' });
|
|
258
|
+
showToast(`Duplicated as "${project.name}"`, 'ok');
|
|
259
|
+
} else if (act.dataset.act === 'archive') {
|
|
260
|
+
await api(`/api/projects/${encodeURIComponent(id)}/archive`, { method: 'POST', body: '{}' });
|
|
261
|
+
showToast('Archived — recoverable from ~/.floless/projects/.archive', 'ok');
|
|
262
|
+
if (current && current.id === id) { closeProject(); return; }
|
|
263
|
+
}
|
|
264
|
+
} catch (err) { showToast('Action failed: ' + (err && err.message || err), 'warn'); }
|
|
265
|
+
loadProjects();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
window.flolessWorkspaces = { setMode, refresh: loadProjects };
|
|
269
|
+
applyMode(); // restore persisted mode immediately (matches panels.js applyView timing)
|
|
270
|
+
})();
|