@floless/app 0.83.0 → 0.85.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 +188 -89
- package/dist/templates/steel-model.flo +6 -0
- package/dist/templates/vectorize.flo +6 -0
- package/dist/web/analytics.js +31 -0
- package/dist/web/app.css +19 -2
- package/dist/web/aware.js +167 -17
- package/dist/web/index.html +13 -2
- package/dist/web/steel-3d-view.js +10 -0
- package/dist/web/steel-editor.html +5 -0
- package/dist/web/vector-editor.html +29 -7
- package/dist/web/workspaces.js +283 -37
- package/package.json +1 -1
package/dist/web/workspaces.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
if (!res.ok || body.ok === false) { const e = new Error(body.error || `HTTP ${res.status}`); e.body = body; throw e; }
|
|
20
20
|
return body;
|
|
21
21
|
});
|
|
22
|
+
// Product-analytics seam (analytics.js, loaded first). A no-op until PostHog is configured;
|
|
23
|
+
// itself already swallows errors, but guard the lookup too so a missing global never throws.
|
|
24
|
+
const track = (name, props) => { try { if (window.trackEvent) window.trackEvent(name, props || {}); } catch { /* analytics never breaks UX */ } };
|
|
22
25
|
|
|
23
26
|
const $app = document.getElementById('app');
|
|
24
27
|
const $switch = document.getElementById('mode-switch');
|
|
@@ -39,17 +42,9 @@
|
|
|
39
42
|
const $drawingsBar = document.getElementById('ws-drawings-bar');
|
|
40
43
|
const $revisionCard = document.getElementById('ws-revision-card');
|
|
41
44
|
const $revisionFile = document.getElementById('ws-revision-file');
|
|
45
|
+
const $sourcePlaceholder = document.getElementById('ws-source-placeholder'); // shown for a filter-less Source step
|
|
42
46
|
let currentStep = 'model';
|
|
43
47
|
|
|
44
|
-
// The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
|
|
45
|
-
// line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
|
|
46
|
-
// Tekla is an ACTION (host mutation, no file). `kind` matches the server's export route + listing.
|
|
47
|
-
const EXPORT_CARDS = [
|
|
48
|
-
{ kind: 'bom-csv', ico: '▤', name: 'Bill of materials', fmt: 'CSV', verb: 'Export CSV', file: true, open: true },
|
|
49
|
-
{ kind: 'bom-xlsx', ico: '▦', name: 'Bill of materials', fmt: 'XLSX', verb: 'Export Excel', file: true, open: true },
|
|
50
|
-
{ kind: 'ifc', ico: '◈', name: 'Model', fmt: 'IFC4', verb: 'Export IFC', file: true, open: false },
|
|
51
|
-
{ kind: 'tekla', ico: '◧', name: 'Tekla', fmt: '.ifc → Open API', verb: 'Send to Tekla', file: false },
|
|
52
|
-
];
|
|
53
48
|
let exportState = null; // kind → { path, exportedAt } after a successful list; null = unknown/failed
|
|
54
49
|
if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
|
|
55
50
|
|
|
@@ -58,12 +53,86 @@
|
|
|
58
53
|
const closeProjMenu = () => { $projMenu.hidden = true; $projMenu.classList.remove('show'); };
|
|
59
54
|
|
|
60
55
|
const LS_MODE = 'floless:mode';
|
|
61
|
-
|
|
56
|
+
// Per-app Workspaces presentation. The flag (floless.workspace, surfaced on /api/apps) says WHICH
|
|
57
|
+
// apps are workspaces; this map says HOW to render each (presentation is ours — spec §3.2). Two
|
|
58
|
+
// first-party apps today; promote to a .flo-declared descriptor when a user-authored one appears.
|
|
59
|
+
// NB: both apps keep the SAME data-step keys (drawings/model/exports/history) so setStep() and the
|
|
60
|
+
// frame slots are unchanged — only the first two LABELS and which HTML each frame loads differ.
|
|
61
|
+
//
|
|
62
|
+
// `exports`: the Exports-step cards for the app. `file:true` = writes a file on disk (shown with a
|
|
63
|
+
// "✓ exported HH:MM" line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app
|
|
64
|
+
// for a .ifc). Tekla is an ACTION (host mutation, no file). `kind` matches the server's export route
|
|
65
|
+
// + listing (projectExportFiles). renderExports()/exportUrl() read wsApp().exports — never a global.
|
|
66
|
+
const WORKSPACE_APPS = {
|
|
67
|
+
'steel-model': {
|
|
68
|
+
seed: 'takeoff', // "+ Import the current takeoff"
|
|
69
|
+
editor: '/steel-editor.html',
|
|
70
|
+
filter: '/steel-filter.html',
|
|
71
|
+
steps: [
|
|
72
|
+
{ key: 'drawings', label: 'Drawings' },
|
|
73
|
+
{ key: 'model', label: 'Model' },
|
|
74
|
+
{ key: 'exports', label: 'Exports' },
|
|
75
|
+
{ key: 'history', label: 'History' },
|
|
76
|
+
],
|
|
77
|
+
diff: true, // steel semantic diff expander
|
|
78
|
+
exports: [
|
|
79
|
+
{ kind: 'bom-csv', ico: '▤', name: 'Bill of materials', fmt: 'CSV', verb: 'Export CSV', file: true, open: true },
|
|
80
|
+
{ kind: 'bom-xlsx', ico: '▦', name: 'Bill of materials', fmt: 'XLSX', verb: 'Export Excel', file: true, open: true },
|
|
81
|
+
{ kind: 'ifc', ico: '◈', name: 'Model', fmt: 'IFC4', verb: 'Export IFC', file: true, open: false },
|
|
82
|
+
{ kind: 'tekla', ico: '◧', name: 'Tekla', fmt: '.ifc → Open API', verb: 'Send to Tekla', file: false },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
vectorize: {
|
|
86
|
+
seed: 'drawing', // drop-a-drawing (Task 6)
|
|
87
|
+
editor: '/vector-editor.html',
|
|
88
|
+
filter: null, // no filter step — Source shows a lightweight placeholder
|
|
89
|
+
steps: [
|
|
90
|
+
{ key: 'drawings', label: 'Source' }, // reuse the 'drawings' frame slot; label "Source"
|
|
91
|
+
{ key: 'model', label: 'Vectors' }, // reuse the 'model' frame slot; label "Vectors"
|
|
92
|
+
{ key: 'exports', label: 'Exports' },
|
|
93
|
+
{ key: 'history', label: 'History' },
|
|
94
|
+
],
|
|
95
|
+
diff: false,
|
|
96
|
+
exports: [
|
|
97
|
+
{ kind: 'svg', ico: '▨', name: 'Vectors', fmt: 'SVG', verb: 'Export SVG', file: true, open: true },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const wsApp = () => (current ? WORKSPACE_APPS[current.app] : null);
|
|
62
102
|
let mode = 'workflows';
|
|
63
103
|
try { if (localStorage.getItem(LS_MODE) === 'workspaces') mode = 'workspaces'; } catch { /* private mode */ }
|
|
64
104
|
let projects = [];
|
|
105
|
+
// Installed workspace apps, in descriptor order. Populated from /api/apps in loadProjects():
|
|
106
|
+
// the set = apps flagged workspace===true that ALSO have a WORKSPACE_APPS descriptor (an app
|
|
107
|
+
// flagged workspace with no descriptor is skipped with a console note — never a broken open).
|
|
108
|
+
// Drives the per-app create cards. Falls back to the descriptor keys if /api/apps can't be read.
|
|
109
|
+
let wsAppIds = Object.keys(WORKSPACE_APPS);
|
|
65
110
|
let current = null; // the open project (null = landing)
|
|
66
111
|
|
|
112
|
+
// The open app's visible label for a step key (Model→"Model" for steel, "Vectors" for vectorize) —
|
|
113
|
+
// so shared chrome (banners, prompts) names the app's OWN step, never hard-coded steel vocabulary.
|
|
114
|
+
// Falls back to a capitalized key if the descriptor is missing a step.
|
|
115
|
+
const stepLabel = (key) => {
|
|
116
|
+
const d = wsApp();
|
|
117
|
+
const st = d && d.steps.find((s) => s.key === key);
|
|
118
|
+
return (st && st.label) || (key.charAt(0).toUpperCase() + key.slice(1));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Workspaces guide beacon (aware.js owns #guide-beacon; we own project state) ──────
|
|
122
|
+
// The document-lifecycle guide (drop → review → approve → export) is for the drop-a-drawing
|
|
123
|
+
// DOCUMENT archetype (vectorize today, `seed:'drawing'`), not the pipeline-import archetype
|
|
124
|
+
// (steel `seed:'takeoff'`, which already onboards via the Workflows beacon). Hand aware.js a
|
|
125
|
+
// small snapshot of the open project's lifecycle state; it repaints the shared beacon/modal.
|
|
126
|
+
const wsHasDocGuide = () => { const d = wsApp(); return !!(d && d.seed === 'drawing'); };
|
|
127
|
+
function syncWorkspaceGuide() {
|
|
128
|
+
if (!bridge.setWorkspaceGuide) return; // older bridge — no-op (beacon simply stays hidden)
|
|
129
|
+
if (!current || !wsHasDocGuide()) { bridge.clearWorkspaceGuide && bridge.clearWorkspaceGuide(); return; }
|
|
130
|
+
// `exported` = an SVG export file exists (from the last export listing); best-known from
|
|
131
|
+
// exportState, which renderExports() populates. Unknown before Exports is opened → false.
|
|
132
|
+
const exported = !!(exportState && exportState.svg && exportState.svg.exportedAt);
|
|
133
|
+
bridge.setWorkspaceGuide({ app: current.app, name: current.name, approved: !!current.approvedAt, exported });
|
|
134
|
+
}
|
|
135
|
+
|
|
67
136
|
// ── mode ────────────────────────────────────────────────────────────────────
|
|
68
137
|
function applyMode() {
|
|
69
138
|
const ws = mode === 'workspaces';
|
|
@@ -78,11 +147,20 @@
|
|
|
78
147
|
$spine.hidden = !ws || !current;
|
|
79
148
|
if (!ws && !$projMenu.hidden) closeProjMenu();
|
|
80
149
|
if (ws && !current) loadProjects();
|
|
150
|
+
// Keep the shared guide beacon in step with mode: in Workflows mode (or the Workspaces
|
|
151
|
+
// landing) it must NOT show a workspace guide — clear it so aware.js reverts to the
|
|
152
|
+
// workflow beacon; in a Workspaces project it points at that project's lifecycle.
|
|
153
|
+
if (!ws || !current) { if (bridge.clearWorkspaceGuide) bridge.clearWorkspaceGuide(); }
|
|
154
|
+
else syncWorkspaceGuide();
|
|
81
155
|
}
|
|
82
156
|
function setMode(m) {
|
|
83
|
-
|
|
157
|
+
const next = m === 'workspaces' ? 'workspaces' : 'workflows';
|
|
158
|
+
const changed = next !== mode;
|
|
159
|
+
mode = next;
|
|
84
160
|
try { localStorage.setItem(LS_MODE, mode); } catch { /* private mode */ }
|
|
85
161
|
applyMode();
|
|
162
|
+
// Real user intent: a mode toggle (not the boot/render applyMode). Only on an actual change.
|
|
163
|
+
if (changed) track('workspaces_mode_switched', { mode });
|
|
86
164
|
}
|
|
87
165
|
$switch.addEventListener('click', (e) => {
|
|
88
166
|
const b = e.target.closest('button[data-mode]');
|
|
@@ -112,6 +190,25 @@
|
|
|
112
190
|
// (the empty-state CTA would be a lie: "no projects yet" when the truth is "load failed").
|
|
113
191
|
try { ({ projects } = await api('/api/projects')); }
|
|
114
192
|
catch (err) { projects = []; showToast('Couldn’t load projects: ' + (err && err.message || err), 'warn'); }
|
|
193
|
+
// Which workspace apps to offer a create card for: those flagged workspace===true on /api/apps
|
|
194
|
+
// that we ALSO know how to render (have a WORKSPACE_APPS descriptor). Keep descriptor order for a
|
|
195
|
+
// stable card order. A load failure leaves wsAppIds at its descriptor-key fallback (still usable).
|
|
196
|
+
try {
|
|
197
|
+
const { apps } = await api('/api/apps');
|
|
198
|
+
const flagged = new Set((apps || []).filter((a) => a && a.workspace === true).map((a) => a.id));
|
|
199
|
+
wsAppIds = Object.keys(WORKSPACE_APPS).filter((id) => flagged.has(id));
|
|
200
|
+
// Hardening: /api/apps loaded but flagged NONE of our known workspace apps (a transient
|
|
201
|
+
// appWorkspace read blip returning false for all). Rather than render zero create cards, fall
|
|
202
|
+
// back to the descriptor keys — the same usable default a full /api/apps failure lands on.
|
|
203
|
+
if (wsAppIds.length === 0 && Object.keys(WORKSPACE_APPS).length > 0) {
|
|
204
|
+
wsAppIds = Object.keys(WORKSPACE_APPS);
|
|
205
|
+
}
|
|
206
|
+
for (const a of apps || []) {
|
|
207
|
+
if (a && a.workspace === true && !WORKSPACE_APPS[a.id]) {
|
|
208
|
+
console.warn(`[workspaces] app "${a.id}" is flagged workspace but has no client descriptor — skipping its create card`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch { /* keep the descriptor-key fallback — the create cards still work */ }
|
|
115
212
|
renderLanding();
|
|
116
213
|
}
|
|
117
214
|
|
|
@@ -127,12 +224,32 @@
|
|
|
127
224
|
`<button type="button" class="ws-kebab" data-kebab="${escapeAttr(p.id)}" aria-label="Project actions" data-tip="Rename · Duplicate · Archive">⋯</button>` +
|
|
128
225
|
`</span></div>`;
|
|
129
226
|
}
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
227
|
+
// One create card per installed workspace app, driven by its descriptor's seed behavior:
|
|
228
|
+
// • seed:'takeoff' (steel-model) — click-to-import the app's current takeoff (Slice-1 flow, unchanged).
|
|
229
|
+
// • seed:'drawing' (vectorize) — a drop/pick affordance that starts a project from a dropped file.
|
|
230
|
+
for (const id of wsAppIds) {
|
|
231
|
+
const d = WORKSPACE_APPS[id];
|
|
232
|
+
if (!d) continue;
|
|
233
|
+
if (d.seed === 'drawing') {
|
|
234
|
+
html += `<button type="button" class="ws-seed ws-seed-drop" data-seed-app="${escapeAttr(id)}" data-seed-drop="${escapeAttr(id)}">` +
|
|
235
|
+
`<span class="plus">+</span>` +
|
|
236
|
+
`<span class="ws-seed-headline">Drop a drawing to start a new Vectorize project</span>` +
|
|
237
|
+
`<span class="ws-seed-sub">PDF or image — vectors come out editable, ready to export as SVG.</span>` +
|
|
238
|
+
`</button>`;
|
|
239
|
+
} else {
|
|
240
|
+
html += `<button type="button" class="ws-seed" data-seed-app="${escapeAttr(id)}">` +
|
|
241
|
+
`<span class="plus">+</span><span>Import the current ${escapeHtml(id)} takeoff as a project</span></button>`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
134
244
|
html += `</div>`;
|
|
135
|
-
|
|
245
|
+
// Empty-note copy adapts to which create affordances are on screen: a vectorize card means the
|
|
246
|
+
// user can drop a drawing right above; otherwise it's the steel import line.
|
|
247
|
+
if (!projects.length) {
|
|
248
|
+
const hasDrawingSeed = wsAppIds.some((id) => WORKSPACE_APPS[id] && WORKSPACE_APPS[id].seed === 'drawing');
|
|
249
|
+
html += hasDrawingSeed
|
|
250
|
+
? `<div class="ws-empty-note">No projects yet — drop a drawing above, or ask your terminal AI to vectorize a drawing set.</div>`
|
|
251
|
+
: `<div class="ws-empty-note">No projects yet — import your current takeoff above, or ask your terminal AI to read a drawing set.</div>`;
|
|
252
|
+
}
|
|
136
253
|
$landing.innerHTML = html;
|
|
137
254
|
}
|
|
138
255
|
|
|
@@ -141,7 +258,13 @@
|
|
|
141
258
|
if (kebab) { e.stopPropagation(); openProjMenu(kebab.dataset.kebab, kebab); return; }
|
|
142
259
|
const card = e.target.closest('[data-open]');
|
|
143
260
|
if (card) { openProject(card.dataset.open); return; }
|
|
144
|
-
|
|
261
|
+
const seed = e.target.closest('[data-seed-app]');
|
|
262
|
+
if (seed) {
|
|
263
|
+
const id = seed.dataset.seedApp;
|
|
264
|
+
const d = WORKSPACE_APPS[id];
|
|
265
|
+
if (d && d.seed === 'drawing') { seedPickApp = id; $seedFile.click(); } // drop-a-drawing: open the picker
|
|
266
|
+
else createSeeded(id); // takeoff import
|
|
267
|
+
}
|
|
145
268
|
});
|
|
146
269
|
$landing.addEventListener('keydown', (e) => {
|
|
147
270
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
@@ -149,49 +272,156 @@
|
|
|
149
272
|
if (card) { e.preventDefault(); openProject(card.dataset.open); }
|
|
150
273
|
});
|
|
151
274
|
|
|
275
|
+
// Drop-a-drawing dropzone. A single hidden picker (outside $landing so an innerHTML re-render can't
|
|
276
|
+
// wipe it) backs the click-to-pick; dragover/drop are delegated on $landing so they survive the
|
|
277
|
+
// re-render too. The dashed card lights up (.drag-over) while a file hovers it.
|
|
278
|
+
let seedPickApp = null; // which app the pending pick/drop is for (set on click/drop)
|
|
279
|
+
const $seedFile = document.createElement('input');
|
|
280
|
+
$seedFile.type = 'file'; $seedFile.accept = '.pdf,image/*'; $seedFile.hidden = true;
|
|
281
|
+
document.body.appendChild($seedFile);
|
|
282
|
+
$seedFile.addEventListener('change', () => {
|
|
283
|
+
const file = ($seedFile.files || [])[0];
|
|
284
|
+
$seedFile.value = ''; // clear so re-picking the same file re-fires change
|
|
285
|
+
if (file && seedPickApp) createFromDrawing(seedPickApp, file);
|
|
286
|
+
});
|
|
287
|
+
$landing.addEventListener('dragover', (e) => {
|
|
288
|
+
const zone = e.target.closest('[data-seed-drop]');
|
|
289
|
+
if (!zone) return;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
zone.classList.add('drag-over');
|
|
292
|
+
});
|
|
293
|
+
$landing.addEventListener('dragleave', (e) => {
|
|
294
|
+
const zone = e.target.closest('[data-seed-drop]');
|
|
295
|
+
if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove('drag-over');
|
|
296
|
+
});
|
|
297
|
+
$landing.addEventListener('drop', (e) => {
|
|
298
|
+
const zone = e.target.closest('[data-seed-drop]');
|
|
299
|
+
if (!zone) return;
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
zone.classList.remove('drag-over');
|
|
302
|
+
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
|
303
|
+
if (file) createFromDrawing(zone.dataset.seedDrop, file);
|
|
304
|
+
});
|
|
305
|
+
|
|
152
306
|
async function promptName(title, label, value) {
|
|
153
307
|
if (!bridge.formModal) return null;
|
|
154
308
|
const res = await bridge.formModal({ title, fields: [{ name: 'name', label, value, placeholder: 'e.g. Westfield Retail — Ph2' }], okLabel: 'Save' });
|
|
155
309
|
return res && typeof res.name === 'string' && res.name.trim() ? res.name.trim() : null;
|
|
156
310
|
}
|
|
157
311
|
|
|
158
|
-
async function createSeeded() {
|
|
312
|
+
async function createSeeded(appId) {
|
|
159
313
|
const name = await promptName('New project', 'Name this project', '');
|
|
160
314
|
if (!name) return;
|
|
161
315
|
try {
|
|
162
|
-
const { project, seeded } = await api('/api/projects', { method: 'POST', body: JSON.stringify({ name, app:
|
|
316
|
+
const { project, seeded } = await api('/api/projects', { method: 'POST', body: JSON.stringify({ name, app: appId, seedFromApp: true }) });
|
|
163
317
|
// Tell the truth about the import: seeded=false means there was no current takeoff to copy
|
|
164
318
|
// (or it was unreadable) — the project is EMPTY, don't imply the takeoff came across.
|
|
165
|
-
if (seeded) showToast(`Project "${project.name}" created from the current ${
|
|
166
|
-
else showToast(`Project "${project.name}" created — empty (no current ${
|
|
319
|
+
if (seeded) showToast(`Project "${project.name}" created from the current ${appId} takeoff`, 'ok');
|
|
320
|
+
else showToast(`Project "${project.name}" created — empty (no current ${appId} takeoff to import)`, 'warn');
|
|
321
|
+
track('workspace_project_created', { app: appId }); // takeoff-import create path
|
|
167
322
|
projects.unshift(project);
|
|
168
323
|
openProject(project.id);
|
|
169
324
|
} catch (err) { showToast('Couldn’t create the project: ' + (err && err.message || err), 'warn'); }
|
|
170
325
|
}
|
|
171
326
|
|
|
327
|
+
// Drop-a-drawing create (vectorize). No new server verb: create an EMPTY project, then fire the
|
|
328
|
+
// existing revision pipeline (attachRevision) with the dropped file so the terminal AI reads it into
|
|
329
|
+
// v1. ROLLBACK on cancel/error — if the user cancels the name prompt, or the file can't be read,
|
|
330
|
+
// AFTER the row is created, archive the empty project so no dead, openable vectorize project is left
|
|
331
|
+
// behind (the row is created before the source exists — spec §4.1).
|
|
332
|
+
async function createFromDrawing(appId, file) {
|
|
333
|
+
// Ask for the name FIRST (before creating a row) so the common "cancel the prompt" path never
|
|
334
|
+
// creates anything to roll back.
|
|
335
|
+
const name = await promptName('New Vectorize project', 'Name this project', '');
|
|
336
|
+
if (!name) return;
|
|
337
|
+
let project;
|
|
338
|
+
try {
|
|
339
|
+
({ project } = await api('/api/projects', { method: 'POST', body: JSON.stringify({ name, app: appId, seedFromApp: false }) }));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
showToast('Couldn’t create the project: ' + (err && err.message || err), 'warn');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Validate the file is actually readable BEFORE queuing — a read failure here (after the row
|
|
345
|
+
// exists) must roll the empty project back rather than leave a dead one openable. attachRevision
|
|
346
|
+
// re-reads the file for the real pipeline; a tiny second read of the same file is harmless.
|
|
347
|
+
try { await readFileAsSnapshot(file); }
|
|
348
|
+
catch {
|
|
349
|
+
// Tailor the toast to what actually happened server-side: only claim the empty project was
|
|
350
|
+
// removed if the archive POST succeeded — otherwise it's still there and we must say so.
|
|
351
|
+
const removed = await archiveEmptyProject(project.id);
|
|
352
|
+
showToast(
|
|
353
|
+
removed
|
|
354
|
+
? 'Couldn’t read the dropped drawing — the empty project was removed.'
|
|
355
|
+
: 'Couldn’t read the dropped drawing — an empty project was left behind; archive it from the ⋯ menu.',
|
|
356
|
+
'warn',
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
track('workspace_project_created', { app: appId }); // drop-a-drawing create path (only once the row is kept, not rolled back)
|
|
361
|
+
projects.unshift(project);
|
|
362
|
+
openProject(project.id); // opens onto the project; the revision card reflects the queued read
|
|
363
|
+
attachRevision([file]); // reads the file → POSTs the revision-read Request for THIS project (self-toasting)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Best-effort rollback of a just-created empty project (the existing archive path — recoverable).
|
|
367
|
+
// Returns true if the archive POST succeeded (removed server-side), false if it failed (a dead
|
|
368
|
+
// empty project was left behind) so the caller can tell the truth in its toast. Either way the
|
|
369
|
+
// local row is dropped so the landing doesn't keep showing it.
|
|
370
|
+
async function archiveEmptyProject(id) {
|
|
371
|
+
let removed = true;
|
|
372
|
+
try { await api(`/api/projects/${encodeURIComponent(id)}/archive`, { method: 'POST', body: '{}' }); }
|
|
373
|
+
catch { removed = false; /* leave it — the landing simply shows an empty project the user can archive manually */ }
|
|
374
|
+
projects = projects.filter((p) => p.id !== id);
|
|
375
|
+
return removed;
|
|
376
|
+
}
|
|
377
|
+
|
|
172
378
|
// ── open project ────────────────────────────────────────────────────────────
|
|
173
379
|
async function openProject(id) {
|
|
174
380
|
let p = projects.find((x) => x.id === id);
|
|
175
381
|
if (!p) { try { p = (await api('/api/projects')).projects.find((x) => x.id === id); } catch { /* fall through */ } }
|
|
176
382
|
if (!p) { showToast('Project not found — it may have been archived.', 'warn'); return loadProjects(); }
|
|
383
|
+
// Never a broken open (spec §3.2): a project whose app has no client descriptor (uninstalled or
|
|
384
|
+
// renamed workspace app) can't be rendered — toast and bail BEFORE mutating any view state,
|
|
385
|
+
// matching the create-card skip. wsApp() would return null for it and the frame wiring would fail.
|
|
386
|
+
if (!WORKSPACE_APPS[p.app]) { showToast('This project’s app isn’t available as a workspace.', 'warn'); return; }
|
|
177
387
|
current = p;
|
|
388
|
+
track('workspace_project_opened', { app: p.app }); // a real open (card click or post-create landing)
|
|
178
389
|
lastRevState = 'none'; stopRevisionPoll(); // fresh project — drop any prior revision-poll state
|
|
179
390
|
$projName.textContent = p.name;
|
|
180
391
|
$crumbName.textContent = p.name;
|
|
181
392
|
$status.textContent = p.app;
|
|
182
393
|
// Lazily src the frames once per project; switching steps only toggles hidden so the
|
|
183
|
-
// editor's
|
|
394
|
+
// editor's state survives a Source⇄Vectors round-trip. Frame URLs come from the app descriptor:
|
|
395
|
+
// the 'model' frame loads the app's editor; the 'drawings' frame loads its filter (steel) OR is
|
|
396
|
+
// left empty when the app has no filter step (vectorize — its Source step shows a placeholder).
|
|
397
|
+
const d = WORKSPACE_APPS[p.app]; // guaranteed present — the guard above bailed on an unknown app
|
|
184
398
|
const qs = `?app=${encodeURIComponent(p.app)}&project=${encodeURIComponent(p.id)}`;
|
|
185
|
-
frames.model.dataset.want =
|
|
186
|
-
frames.drawings.dataset.want =
|
|
399
|
+
frames.model.dataset.want = `${d.editor}${qs}`;
|
|
400
|
+
frames.drawings.dataset.want = d.filter ? `${d.filter}${qs}` : '';
|
|
401
|
+
renderStepTabs(); // paint the tab labels for THIS app before the first step shows
|
|
187
402
|
frames.model.src = 'about:blank'; frames.drawings.src = 'about:blank'; // reset any previous project
|
|
188
403
|
frames.model.dataset.loaded = ''; frames.drawings.dataset.loaded = '';
|
|
189
404
|
setStep('model');
|
|
190
405
|
applyMode();
|
|
406
|
+
syncWorkspaceGuide(); // point the shared beacon at THIS project's document lifecycle
|
|
191
407
|
}
|
|
192
|
-
function closeProject() { current = null; stopRevisionPoll(); lastRevState = 'none'; $revisionCard.hidden = true; closeProjMenu(); applyMode(); }
|
|
408
|
+
function closeProject() { current = null; stopRevisionPoll(); lastRevState = 'none'; $revisionCard.hidden = true; closeProjMenu(); applyMode(); syncWorkspaceGuide(); }
|
|
193
409
|
document.getElementById('ws-back').addEventListener('click', closeProject);
|
|
194
410
|
|
|
411
|
+
// Paint the step-tab LABELS from the current app's descriptor (Source/Vectors for vectorize,
|
|
412
|
+
// Drawings/Model for steel). The data-step KEYS stay stable (drawings/model/exports/history) so
|
|
413
|
+
// setStep() and the frame slots are untouched — only the visible text differs. Label is set via
|
|
414
|
+
// textContent (never innerHTML) — it's a descriptor string, but textContent is the safe default.
|
|
415
|
+
function renderStepTabs() {
|
|
416
|
+
const d = wsApp();
|
|
417
|
+
if (!d) return;
|
|
418
|
+
const byKey = new Map(d.steps.map((st) => [st.key, st.label]));
|
|
419
|
+
$stepTabs.querySelectorAll('button[data-step]').forEach((b) => {
|
|
420
|
+
const label = byKey.get(b.dataset.step);
|
|
421
|
+
if (label) b.textContent = label;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
195
425
|
function setStep(s) {
|
|
196
426
|
currentStep = s;
|
|
197
427
|
$stepTabs.querySelectorAll('button').forEach((b) => {
|
|
@@ -199,11 +429,16 @@
|
|
|
199
429
|
b.classList.toggle('active', on);
|
|
200
430
|
b.setAttribute('aria-selected', String(on));
|
|
201
431
|
});
|
|
432
|
+
// An app with no filter (vectorize) has an empty `drawings` frame URL — never src it; its Source
|
|
433
|
+
// step shows a lightweight placeholder pane instead of an about:blank iframe.
|
|
434
|
+
const d = wsApp();
|
|
435
|
+
const drawingsHasFrame = !!(d && d.filter);
|
|
202
436
|
for (const [k, f] of Object.entries(frames)) {
|
|
203
|
-
const show = k === s;
|
|
437
|
+
const show = k === s && (k !== 'drawings' || drawingsHasFrame);
|
|
204
438
|
f.hidden = !show;
|
|
205
|
-
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
439
|
+
if (show && !f.dataset.loaded && f.dataset.want) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
206
440
|
}
|
|
441
|
+
if ($sourcePlaceholder) $sourcePlaceholder.hidden = !(s === 'drawings' && !drawingsHasFrame);
|
|
207
442
|
$drawingsBar.hidden = s !== 'drawings';
|
|
208
443
|
renderRevisionCard(); // reflect any pending/failed revision read (card shows only on the Drawings step)
|
|
209
444
|
// Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
|
|
@@ -222,7 +457,7 @@
|
|
|
222
457
|
});
|
|
223
458
|
|
|
224
459
|
// ── Approve (per-project) ───────────────────────────────────────────────────
|
|
225
|
-
// Shared by the header ✓ Approve and the Exports-tab gate link ("Approve
|
|
460
|
+
// Shared by the header ✓ Approve and the Exports-tab gate link ("Approve this version"), so the
|
|
226
461
|
// sign-off is one flow. On success it stamps current.approvedAt (server returns it) so the
|
|
227
462
|
// Exports gate unlocks without a reload.
|
|
228
463
|
async function approveProject() {
|
|
@@ -240,6 +475,11 @@
|
|
|
240
475
|
const res = await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
241
476
|
if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
|
|
242
477
|
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
478
|
+
track('workspace_project_approved', { app: current.app }); // a successful sign-off
|
|
479
|
+
// First Approve for this app calms the "✦ Start here" beacon (the document-archetype
|
|
480
|
+
// analog of a workflow's first Run); ticks the Review + Approve guide rows.
|
|
481
|
+
if (bridge.markWorkspaceApproved && wsHasDocGuide()) bridge.markWorkspaceApproved(current.app);
|
|
482
|
+
syncWorkspaceGuide();
|
|
243
483
|
if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
|
|
244
484
|
if (!$wsHistory.hidden) renderHistory(); // a new version was recorded — reflect it + clear the banner
|
|
245
485
|
} catch (err) {
|
|
@@ -300,6 +540,7 @@
|
|
|
300
540
|
if (kind === 'bom-csv') return `${base}/export-bom/csv${qs}`;
|
|
301
541
|
if (kind === 'bom-xlsx') return `${base}/export-bom/xlsx${qs}`;
|
|
302
542
|
if (kind === 'ifc') return `${base}/export-ifc${qs}`;
|
|
543
|
+
if (kind === 'svg') return `${base}/export-svg${qs}`;
|
|
303
544
|
return `${base}/export-tekla${qs}`; // tekla
|
|
304
545
|
}
|
|
305
546
|
|
|
@@ -326,7 +567,7 @@
|
|
|
326
567
|
}
|
|
327
568
|
const exported = approved && c.file && exportState && exportState[c.kind] && exportState[c.kind].exportedAt;
|
|
328
569
|
const label = exported ? 'Re-export' : c.verb;
|
|
329
|
-
const dis = approved ? '' : ' disabled data-tip="Approve
|
|
570
|
+
const dis = approved ? '' : ' disabled data-tip="Approve this version first"';
|
|
330
571
|
return `<div class="ecard">` +
|
|
331
572
|
`<div class="e-top"><div class="e-ico">${escapeHtml(c.ico)}</div>` +
|
|
332
573
|
`<div><div class="e-name">${escapeHtml(c.name)}</div><div class="e-fmt">${escapeHtml(c.fmt)}</div></div></div>` +
|
|
@@ -357,25 +598,29 @@
|
|
|
357
598
|
// The user may have switched project/step during the await — don't paint stale.
|
|
358
599
|
if (!current || current.id !== id || $wsExports.hidden) return;
|
|
359
600
|
exportState = state;
|
|
601
|
+
syncWorkspaceGuide(); // fresh approve/export state → tick the Export (and re-sync Approve) guide rows
|
|
360
602
|
const approved = !!approvedAt;
|
|
361
603
|
let html = '';
|
|
362
604
|
if (!approved) {
|
|
363
605
|
html += `<div class="ws-exports-gate">Exports go out after sign-off — ` +
|
|
364
|
-
`<button type="button" id="ws-exports-approve">Approve
|
|
606
|
+
`<button type="button" id="ws-exports-approve">Approve this version</button> to enable them.</div>`;
|
|
365
607
|
}
|
|
366
608
|
html += `<div class="exports-note"><b>Export at any stage</b> — polishing is optional. ` +
|
|
367
609
|
`Each writes to this project’s <code>exports</code> folder.</div>`;
|
|
368
|
-
|
|
610
|
+
const cards = (wsApp() && wsApp().exports) || [];
|
|
611
|
+
html += '<div class="export-grid">' + cards.map((c) => cardHtml(c, approved)).join('') + '</div>';
|
|
369
612
|
$wsExports.innerHTML = html;
|
|
370
613
|
}
|
|
371
614
|
|
|
372
615
|
async function doExport(kind) {
|
|
373
616
|
if (!current) return;
|
|
617
|
+
const app = current.app; // capture — the user may switch projects during the await
|
|
374
618
|
const btn = $wsExports.querySelector(`[data-export="${kind}"]`);
|
|
375
619
|
const prev = btn ? btn.textContent : '';
|
|
376
620
|
if (btn) { btn.disabled = true; btn.textContent = kind === 'tekla' ? 'Baking…' : 'Exporting…'; }
|
|
377
621
|
try {
|
|
378
622
|
const res = await api(exportUrl(kind), { method: 'POST' });
|
|
623
|
+
track('workspace_export', { app, kind }); // a successful export/bake (e.g. { app:'vectorize', kind:'svg' })
|
|
379
624
|
if (kind === 'tekla') {
|
|
380
625
|
const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
|
|
381
626
|
showToast((res.message || 'Baked into the Tekla model.') + (dropped ? ` · ${dropped} RFI member(s) skipped` : ''), 'ok');
|
|
@@ -562,7 +807,7 @@
|
|
|
562
807
|
: `<span class="avatar ai">${escapeHtml((author || '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || 'AI')}</span>`;
|
|
563
808
|
const gateBadge = (gate, kind) => {
|
|
564
809
|
if (gate === 'model')
|
|
565
|
-
return '<span class="gate-badge model" data-tip="
|
|
810
|
+
return '<span class="gate-badge model" data-tip="Sign-off gate — this version was approved">Model</span>';
|
|
566
811
|
if (kind === 'revision-read')
|
|
567
812
|
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
813
|
return '<span class="gate-badge" data-tip="Unsigned — a working-tree restore, not a sign-off">unsigned</span>';
|
|
@@ -575,9 +820,10 @@
|
|
|
575
820
|
const action = v.current
|
|
576
821
|
? ''
|
|
577
822
|
: `<button type="button" class="btn-mini" data-rollback="${escapeAttr(String(v.n))}" data-tip="Restore this version as a new version">↺ Rollback</button>`;
|
|
578
|
-
// "What changed" disclosure — only
|
|
579
|
-
//
|
|
580
|
-
|
|
823
|
+
// "What changed" disclosure — only when the app HAS a semantic diff (steel; wsApp().diff) AND the
|
|
824
|
+
// version has a predecessor (n>1). v1 is the baseline, and a no-diff app (vectorize) gets NO toggle
|
|
825
|
+
// on ANY row — an ABSENT control, never a disabled one (spec §4.4, mirroring the v1-baseline branch).
|
|
826
|
+
const canDiff = !!(wsApp() && wsApp().diff) && Number(v.n) > 1;
|
|
581
827
|
const toggle = canDiff
|
|
582
828
|
? `<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
829
|
: '';
|
|
@@ -620,7 +866,7 @@
|
|
|
620
866
|
if (!rows.length) {
|
|
621
867
|
// Empty = an inline-action banner (never a bare header row that reads as broken/loading).
|
|
622
868
|
$wsHistory.innerHTML =
|
|
623
|
-
'<div class="hist-empty">No versions yet — <button type="button" id="hist-approve">Approve
|
|
869
|
+
'<div class="hist-empty">No versions yet — <button type="button" id="hist-approve">Approve this version</button> ' +
|
|
624
870
|
'to create <b>v1</b> and start the ledger.</div>';
|
|
625
871
|
return;
|
|
626
872
|
}
|
|
@@ -630,7 +876,7 @@
|
|
|
630
876
|
const working = !approvedAt
|
|
631
877
|
? (headKind === 'revision-read'
|
|
632
878
|
? '<div class="hist-working">● <b>The AI read a revised drawing set — review it before signing.</b> ' +
|
|
633
|
-
|
|
879
|
+
`Open <b>${escapeHtml(stepLabel('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
880
|
: '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
|
|
635
881
|
'<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>')
|
|
636
882
|
: '';
|