@floless/app 0.84.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.
@@ -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
- const WORKSPACE_APP = 'steel-model'; // slice 1: the one workspace app; a manifest flag generalizes this in slice 5
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
- mode = m === 'workspaces' ? 'workspaces' : 'workflows';
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
- // Slice-1 creation path: seed a project from the app's current takeoff. The
131
- // compose-time "read a drawing set" flow is Slice 4 — this card is honest about that.
132
- html += `<button type="button" class="ws-seed" id="ws-seed">` +
133
- `<span class="plus">+</span><span>Import the current ${escapeHtml(WORKSPACE_APP)} takeoff as a project</span></button>`;
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
- 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>`;
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
- if (e.target.closest('#ws-seed')) createSeeded();
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: WORKSPACE_APP, seedFromApp: true }) });
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 ${WORKSPACE_APP} takeoff`, 'ok');
166
- else showToast(`Project "${project.name}" created — empty (no current ${WORKSPACE_APP} takeoff to import)`, 'warn');
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 3D state survives a DrawingsModel round-trip.
394
+ // editor's state survives a SourceVectors 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 = `/steel-editor.html${qs}`;
186
- frames.drawings.dataset.want = `/steel-filter.html${qs}`;
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 the model"), so the
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 the model first"';
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 the model</button> to enable them.</div>`;
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
- html += '<div class="export-grid">' + EXPORT_CARDS.map((c) => cardHtml(c, approved)).join('') + '</div>';
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="Model gate — the geometry was signed off">Model</span>';
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 for a version that HAS a predecessor (n>1). v1 is the baseline,
579
- // so it gets NO toggle at all (an ABSENT control, never a disabled one).
580
- const canDiff = Number(v.n) > 1;
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 the model</button> ' +
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
- 'Open <b>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>'
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
  : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.84.0",
3
+ "version": "0.85.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": {