@floless/app 0.72.2 → 0.73.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.72.2" : void 0,
53025
+ define: true ? "0.73.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.72.2" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.73.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -53329,14 +53329,16 @@ function extractReportHtml(events) {
53329
53329
  var import_node_child_process3 = require("node:child_process");
53330
53330
  var import_node_fs12 = require("node:fs");
53331
53331
  var import_node_path11 = require("node:path");
53332
- var OPENABLE_EXTS = /* @__PURE__ */ new Set([".xls", ".xlsx", ".html", ".htm", ".csv"]);
53332
+ var OPEN_EXTS = /* @__PURE__ */ new Set([".xls", ".xlsx", ".html", ".htm", ".csv"]);
53333
+ var REVEAL_EXTS = /* @__PURE__ */ new Set([...OPEN_EXTS, ".ifc", ".nc1", ".dstv"]);
53333
53334
  function hasOpenableExt(path) {
53334
- return OPENABLE_EXTS.has((0, import_node_path11.extname)(path).toLowerCase());
53335
+ return OPEN_EXTS.has((0, import_node_path11.extname)(path).toLowerCase());
53335
53336
  }
53336
- function validateOpenable(path) {
53337
+ function validateOpenable(path, mode = "open") {
53337
53338
  if (typeof path !== "string" || !path.trim()) return { ok: false, error: "path required" };
53338
53339
  if (path.includes("%")) return { ok: false, error: "unsupported file path" };
53339
- if (!hasOpenableExt(path)) return { ok: false, error: "unsupported file type" };
53340
+ const allowed = mode === "reveal" ? REVEAL_EXTS.has((0, import_node_path11.extname)(path).toLowerCase()) : hasOpenableExt(path);
53341
+ if (!allowed) return { ok: false, error: "unsupported file type" };
53340
53342
  let isFile = false;
53341
53343
  try {
53342
53344
  isFile = (0, import_node_fs12.existsSync)(path) && (0, import_node_fs12.statSync)(path).isFile();
@@ -53973,6 +53975,9 @@ function slugify(name) {
53973
53975
  function projectDir(id) {
53974
53976
  return (0, import_node_path17.join)(DIR2, safeProjectId(id));
53975
53977
  }
53978
+ function projectExportsDir(id) {
53979
+ return (0, import_node_path17.join)(projectDir(id), "exports");
53980
+ }
53976
53981
  var metaPath = (id) => (0, import_node_path17.join)(projectDir(id), "project.json");
53977
53982
  function readMeta(id) {
53978
53983
  const p = metaPath(id);
@@ -54020,9 +54025,19 @@ function renameProject(id, name) {
54020
54025
  writeMeta(next);
54021
54026
  return next;
54022
54027
  }
54023
- function touchProject(id) {
54028
+ function markApproved(id) {
54024
54029
  const meta = readMeta(safeProjectId(id));
54025
- if (meta) writeMeta({ ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
54030
+ if (!meta) throw new ProjectError(`unknown project: ${id}`);
54031
+ const now = (/* @__PURE__ */ new Date()).toISOString();
54032
+ const next = { ...meta, approvedAt: now, updatedAt: now };
54033
+ writeMeta(next);
54034
+ return next;
54035
+ }
54036
+ function clearApproval(id) {
54037
+ const meta = readMeta(safeProjectId(id));
54038
+ if (!meta) return;
54039
+ const { approvedAt: _drop, ...rest } = meta;
54040
+ writeMeta({ ...rest, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
54026
54041
  }
54027
54042
  function duplicateProject(id, name) {
54028
54043
  const src = readMeta(safeProjectId(id));
@@ -63782,6 +63797,9 @@ async function startServer() {
63782
63797
  if (err2 instanceof ContractError) {
63783
63798
  return reply.status(400).send({ ok: false, error: err2.message });
63784
63799
  }
63800
+ if (err2 instanceof ProjectError) {
63801
+ return reply.status(400).send({ ok: false, error: err2.message });
63802
+ }
63785
63803
  if (err2 instanceof RoutineError) {
63786
63804
  return reply.status(err2.status).send({ ok: false, error: err2.message });
63787
63805
  }
@@ -64226,6 +64244,20 @@ async function startServer() {
64226
64244
  if (p.app !== appId) return `project "${project}" belongs to app "${p.app}", not "${appId}"`;
64227
64245
  return null;
64228
64246
  }
64247
+ function projectExportFiles(appId) {
64248
+ return [
64249
+ { kind: "bom-csv", filename: `${appId}-bom.csv` },
64250
+ { kind: "bom-xlsx", filename: `${appId}-bom.xlsx` },
64251
+ { kind: "ifc", filename: `${appId}.ifc` }
64252
+ ];
64253
+ }
64254
+ function projectExportGate(appId, project) {
64255
+ const p = getProject(project);
64256
+ if (!p) return { status: 404, error: `unknown project: ${project}` };
64257
+ if (p.app !== appId) return { status: 404, error: `project "${project}" belongs to app "${p.app}", not "${appId}"` };
64258
+ if (!p.approvedAt) return { status: 409, error: "Approve the model before exporting \u2014 exports go out only after sign-off." };
64259
+ return null;
64260
+ }
64229
64261
  app.get("/api/contract/:appId", async (req, reply) => {
64230
64262
  const project = req.query.project || void 0;
64231
64263
  if (project) {
@@ -64255,7 +64287,7 @@ async function startServer() {
64255
64287
  if (e instanceof ContractError) return reply.status(400).send({ ok: false, error: e.message });
64256
64288
  throw e;
64257
64289
  }
64258
- if (project) touchProject(project);
64290
+ if (project) clearApproval(project);
64259
64291
  broadcast({ type: "contract-changed", appId: req.params.appId, project });
64260
64292
  return { ok: true };
64261
64293
  }
@@ -64287,7 +64319,9 @@ async function startServer() {
64287
64319
  });
64288
64320
  }
64289
64321
  broadcast({ type: "compiled", id: req.params.appId, lockPath: result.lockPath });
64290
- return { ok: true, result };
64322
+ let approvedAt;
64323
+ if (project) approvedAt = markApproved(project).approvedAt;
64324
+ return { ok: true, result, approvedAt };
64291
64325
  });
64292
64326
  app.post(
64293
64327
  "/api/contract/:appId/score",
@@ -64351,6 +64385,24 @@ async function startServer() {
64351
64385
  }
64352
64386
  return null;
64353
64387
  }
64388
+ app.get("/api/contract/:appId/exports", async (req, reply) => {
64389
+ const project = req.query.project;
64390
+ if (!project) return reply.status(400).send({ ok: false, error: "project required" });
64391
+ const bad = projectAppMismatch(req.params.appId, project);
64392
+ if (bad) return reply.status(404).send({ ok: false, error: bad });
64393
+ const dir = projectExportsDir(project);
64394
+ const exports2 = projectExportFiles(req.params.appId).map(({ kind, filename }) => {
64395
+ const path = (0, import_node_path30.join)(dir, filename);
64396
+ let exportedAt = null;
64397
+ try {
64398
+ const st = (0, import_node_fs32.statSync)(path);
64399
+ if (st.isFile()) exportedAt = st.mtime.toISOString();
64400
+ } catch {
64401
+ }
64402
+ return { kind, filename, path, exportedAt };
64403
+ });
64404
+ return { ok: true, approvedAt: getProject(project)?.approvedAt ?? null, exports: exports2 };
64405
+ });
64354
64406
  app.post("/api/contract/:appId/export-3d", async (req, reply) => {
64355
64407
  const doc = readContract(req.params.appId);
64356
64408
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
@@ -64391,7 +64443,12 @@ async function startServer() {
64391
64443
  return { ok: true, report: { ...extracted, interactive: true }, skipped };
64392
64444
  });
64393
64445
  app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
64394
- const doc = readContractForApp(req.params.appId);
64446
+ const project = req.query.project || void 0;
64447
+ if (project) {
64448
+ const g = projectExportGate(req.params.appId, project);
64449
+ if (g) return reply.status(g.status).send({ ok: false, error: g.error });
64450
+ }
64451
+ const doc = project ? readContract(req.params.appId, project) : readContractForApp(req.params.appId);
64395
64452
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
64396
64453
  const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
64397
64454
  let scene;
@@ -64422,7 +64479,8 @@ async function startServer() {
64422
64479
  }
64423
64480
  const companionId = `${req.params.appId}-ifc`;
64424
64481
  const filename = `${req.params.appId}.ifc`;
64425
- const outPath = (0, import_node_path30.join)(appPath(companionId), filename);
64482
+ const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : (0, import_node_path30.join)(appPath(companionId), filename);
64483
+ if (project) (0, import_node_fs32.mkdirSync)((0, import_node_path30.dirname)(outPath), { recursive: true });
64426
64484
  let flo;
64427
64485
  try {
64428
64486
  flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
@@ -64448,10 +64506,15 @@ async function startServer() {
64448
64506
  if (!(0, import_node_fs32.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
64449
64507
  const content = (0, import_node_fs32.readFileSync)(outPath, "utf8");
64450
64508
  broadcast({ type: "apps-changed" });
64451
- return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
64509
+ return { ok: true, filename, savedTo: outPath, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
64452
64510
  });
64453
64511
  app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
64454
- const doc = readContract(req.params.appId);
64512
+ const project = req.query.project || void 0;
64513
+ if (project) {
64514
+ const g = projectExportGate(req.params.appId, project);
64515
+ if (g) return reply.status(g.status).send({ ok: false, error: g.error });
64516
+ }
64517
+ const doc = project ? readContract(req.params.appId, project) : readContract(req.params.appId);
64455
64518
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to bake" });
64456
64519
  const v = validateSteelTakeoff(doc);
64457
64520
  if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
@@ -64510,13 +64573,18 @@ async function startServer() {
64510
64573
  });
64511
64574
  app.post("/api/contract/:appId/export-bom/:format", async (req, reply) => {
64512
64575
  const { appId, format } = req.params;
64576
+ const project = req.query.project || void 0;
64513
64577
  if (format !== "csv" && format !== "xlsx") {
64514
64578
  return reply.status(400).send({ ok: false, error: `unknown format "${format}" \u2014 use csv or xlsx` });
64515
64579
  }
64516
64580
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(appId)) {
64517
64581
  return reply.status(400).send({ ok: false, error: `invalid appId: ${appId}` });
64518
64582
  }
64519
- const doc = readContract(appId);
64583
+ if (project) {
64584
+ const g = projectExportGate(appId, project);
64585
+ if (g) return reply.status(g.status).send({ ok: false, error: g.error });
64586
+ }
64587
+ const doc = readContract(appId, project);
64520
64588
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
64521
64589
  const v = validateSteelTakeoff(doc);
64522
64590
  if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
@@ -64524,9 +64592,9 @@ async function startServer() {
64524
64592
  if (bom.rows.filter((r) => r.Profile !== "TOTAL").length === 0) {
64525
64593
  return reply.status(422).send({ ok: false, error: "no priced members to export \u2014 every member is an RFI (no AISC size / weight yet)" });
64526
64594
  }
64527
- const outPath = bomExportPath(appId, format);
64528
- const dir = (0, import_node_path30.dirname)(outPath);
64529
64595
  const filename = `${appId}-bom.${format}`;
64596
+ const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : bomExportPath(appId, format);
64597
+ const dir = (0, import_node_path30.dirname)(outPath);
64530
64598
  try {
64531
64599
  (0, import_node_fs32.mkdirSync)(dir, { recursive: true });
64532
64600
  if (format === "csv") {
@@ -64916,16 +64984,16 @@ async function startServer() {
64916
64984
  const running = cancelActiveRun();
64917
64985
  return { ok: true, running };
64918
64986
  });
64919
- function fileVerb(launch) {
64987
+ function fileVerb(launch, mode) {
64920
64988
  return async (req, reply) => {
64921
- const check = validateOpenable(req.body?.path);
64989
+ const check = validateOpenable(req.body?.path, mode);
64922
64990
  if (!check.ok) return reply.status(400).send({ ok: false, error: check.error });
64923
64991
  launch(check.path);
64924
64992
  return { ok: true };
64925
64993
  };
64926
64994
  }
64927
- app.post("/api/open-file", fileVerb(launchFile));
64928
- app.post("/api/reveal", fileVerb(revealFile));
64995
+ app.post("/api/open-file", fileVerb(launchFile, "open"));
64996
+ app.post("/api/reveal", fileVerb(revealFile, "reveal"));
64929
64997
  app.post(
64930
64998
  "/api/trigger-run",
64931
64999
  async (req, reply) => {
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] { display:none !important; }
3315
+ .ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[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);
@@ -3390,3 +3390,41 @@ body {
3390
3390
  .tabs.ws-step-tabs { background:transparent; padding:0 6px; }
3391
3391
  .tabs.ws-step-tabs button { flex:0 0 auto; padding:11px 16px; }
3392
3392
  .ws-frame { flex:1; min-height:0; width:100%; border:none; background:var(--bg); }
3393
+
3394
+ /* ── Workspaces ▸ Exports step (shell-rendered pane; export cards over the project's OWN
3395
+ contract). Ports the mockup's .ecard grid into app tokens; all states baseline-faithful. */
3396
+ .ws-exports { flex:1; min-height:0; display:flex; flex-direction:column; }
3397
+ /* The Approve gate — a project's Model must be signed off before its exports go out. Not an
3398
+ error (accent-dim rail, never a danger color); the inline "Approve" deep-links the sign-off. */
3399
+ .ws-exports-gate { flex:none; margin:12px 20px 0; padding:9px 13px; display:flex; align-items:center; gap:6px;
3400
+ background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
3401
+ border-radius:6px; font-size:12px; color:var(--text-muted); }
3402
+ .ws-exports-gate button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
3403
+ .ws-exports-gate button:hover { color:var(--accent-bright); text-decoration:underline; }
3404
+ .exports-note { flex:none; padding:14px 20px 4px; color:var(--text-muted); font-size:12px; }
3405
+ .exports-note b { color:var(--text); font-weight:600; }
3406
+ .exports-note code { font-family:var(--mono); color:var(--accent-bright); font-size:11px; }
3407
+ .export-grid { flex:1; overflow-y:auto; padding:14px 20px 22px; display:grid; gap:14px;
3408
+ grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); align-content:start;
3409
+ scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
3410
+ .ecard { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:15px;
3411
+ display:flex; flex-direction:column; gap:10px; }
3412
+ .ecard .e-top { display:flex; align-items:center; gap:10px; }
3413
+ .ecard .e-ico { width:34px; height:34px; border-radius:7px; background:var(--surface-2);
3414
+ border:1px solid var(--border-strong); display:flex; align-items:center; justify-content:center; font-size:15px; }
3415
+ .ecard .e-name { font-size:13px; font-weight:600; color:var(--text); }
3416
+ .ecard .e-fmt { font-size:10.5px; color:var(--text-dim); font-family:var(--mono); }
3417
+ .ecard .e-meta { font-size:11px; color:var(--text-muted); display:flex; align-items:center;
3418
+ justify-content:space-between; gap:8px; min-height:16px; }
3419
+ .ecard .e-status { color:var(--text-dim); }
3420
+ .ecard .e-done .chk { color:var(--ok); } /* the ✓ */
3421
+ .ecard .e-done .t { color:var(--text-muted); font-family:var(--mono); } /* HH:MM — the scannable datum */
3422
+ .ecard .e-links { display:flex; align-items:center; gap:8px; flex:none; }
3423
+ .ecard .e-link { background:none; border:none; padding:0; font:inherit; font-size:11px; color:var(--text-muted); cursor:pointer; }
3424
+ .ecard .e-link:hover { color:var(--accent); }
3425
+ .ecard .e-link + .e-link { border-left:1px solid var(--border); padding-left:8px; } /* thin divider, keeps them tertiary */
3426
+ .ecard .e-act { margin-top:auto; }
3427
+ .ecard .e-act button { width:100%; padding:7px 10px; font-size:12px; border-radius:5px; font-family:var(--ui);
3428
+ background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
3429
+ .ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
3430
+ .ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
@@ -167,11 +167,15 @@
167
167
  <div class="tabs ws-step-tabs" id="ws-step-tabs" role="tablist" aria-label="Project steps">
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
+ <button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
170
171
  </div>
171
172
  <!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
172
173
  Same-origin like #contract-editor-frame (they call /api/contract directly). -->
173
174
  <iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
174
175
  <iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
176
+ <!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
177
+ contract. workspaces.js fills it on step-switch (renderExports). -->
178
+ <div class="ws-exports" id="ws-exports" hidden></div>
175
179
  </div>
176
180
  <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>
177
181
  <div class="fav-bar" id="fav-bar">
@@ -349,6 +349,19 @@
349
349
  #propPop .ppfoot .seg2{margin-top:0}
350
350
  #propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
351
351
  #propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
352
+ /* Connection read-only mode: no labeling, so hide the label footer + Clear-all; rows aren't clickable. */
353
+ #propPop.connmode .ppfoot{display:none}
354
+ #propPop.connmode #ppClear{display:none}
355
+ #propPop.connmode .pprow{cursor:default;padding-left:14px} /* no checkbox → nudge the name so it doesn't float where the box was */
356
+ /* Selection tree: clickable object rows (connection nodes + parts + member leaves), reusing the legend's
357
+ chevron/swatch/selected-row vocabulary — click selects in the model (Ctrl toggle, Shift range). */
358
+ #propPop .pprow.trow{cursor:pointer;padding-left:6px}
359
+ #propPop .pprow.trow.ptrow{padding-left:24px} /* parts indent under their connection node */
360
+ #propPop .pprow .chev{width:14px;flex:none;color:var(--mut);text-align:center;font-size:9px;cursor:pointer}
361
+ #propPop .pprow .tsw{width:6px;height:6px;flex:none;border-radius:50%;background:var(--mut)} /* a round status dot — deliberately NOT a checkbox (label checkboxes are square inputs) */
362
+ #propPop .pprow.node{font-weight:500}
363
+ #propPop .pprow .cnt{color:var(--mut);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
364
+ #propPop .pprow.selrow{border-left:2px solid var(--brand);background:rgba(59,130,246,.16)}
352
365
  /* thin cluster divider in the 3D toolbar */
353
366
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
354
367
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -1449,6 +1462,37 @@ function connSelInfo(){
1449
1462
  const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
1450
1463
  return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
1451
1464
  }
1465
+ // ── Selection as a browsable tree (Properties popup): group the flat selIds into connections (each with
1466
+ // its parts) + loose members, so a MIXED selection (a connection + members, several connections) renders
1467
+ // as one tree instead of falling back to member-only. connChildIdsEditor = a connection's selectable parts
1468
+ // (copes excluded — subtractive, not in the 3D mesh set). ---
1469
+ function connChildIdsEditor(conn){return Object.keys(partsById||{}).filter(k=>{const e=partsById[k];return e&&e.conn===conn&&e.kind!=='cut';});}
1470
+ function selTree(){
1471
+ const conns=[],cmap={},members=[];
1472
+ for(const id of selIds){
1473
+ const el=(partsById||{})[id];
1474
+ if(el&&el.conn){ if(!cmap[el.conn]){const j=(C.joints||[]).find(x=>x&&x.id===el.conn);cmap[el.conn]={conn:el.conn,kind:j?j.kind:'',main:j?j.main:'',joint:j,childIds:connChildIdsEditor(el.conn)};conns.push(cmap[el.conn]);} }
1475
+ else if(P.members.some(m=>m.id===id)) members.push(id);
1476
+ }
1477
+ return {conns,members};
1478
+ }
1479
+ // Ctrl/Shift multi-select from a popup tree row → drives the model selection (same semantics as the objects
1480
+ // browser / onSelectDim3d). treeRowIds = the flat, in-display-order LEAF ids (expanded parts + members) for
1481
+ // Shift-range; treeAnchor = the range anchor; treeExpanded = which connection nodes are open (transient).
1482
+ let treeExpanded=new Set(), treeAnchor=null, treeRowIds=[];
1483
+ function treeSelectLeaf(id,mods){
1484
+ let next;
1485
+ if(mods&&mods.shift&&treeAnchor!=null&&treeRowIds.includes(treeAnchor)&&treeRowIds.includes(id)){const i0=treeRowIds.indexOf(treeAnchor),i1=treeRowIds.indexOf(id);next=treeRowIds.slice(Math.min(i0,i1),Math.max(i0,i1)+1);}
1486
+ else if(mods&&(mods.ctrl||mods.meta)){next=new Set(selIds);next.has(id)?next.delete(id):next.add(id);next=[...next];treeAnchor=id;}
1487
+ else{next=[id];treeAnchor=id;}
1488
+ selIds=new Set(next);selDimIds.clear();sel3dDimIds.clear();render();
1489
+ }
1490
+ function treeSelectConn(conn,mods){
1491
+ const cids=connChildIdsEditor(conn);
1492
+ if(mods&&(mods.ctrl||mods.meta)){const next=new Set(selIds);const all=cids.length&&cids.every(id=>next.has(id));cids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
1493
+ else if(window.Steel3DView&&window.Steel3DView.selectWholeConn){window.Steel3DView.selectWholeConn(conn);treeAnchor=cids[0]||null;} // plain → whole connection (envelope + inspector), same as a 3D click
1494
+ else{selIds=new Set(cids);selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
1495
+ }
1452
1496
  // The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
1453
1497
  // 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
1454
1498
  function updateConnCrumb(){
@@ -2018,6 +2062,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
2018
2062
 
2019
2063
  // ---- The floating Properties popup ----
2020
2064
  let propPopPinned=false;
2065
+ let propPopConn=null; // truthy = connection/tree mode (selection involves a connection → renderConnTree); null = the member label-picker mode
2021
2066
  function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
2022
2067
  function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2023
2068
  el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
@@ -2032,8 +2077,13 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2032
2077
  +`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
2033
2078
  document.body.appendChild(el);
2034
2079
  const list=el.querySelector('#ppList');
2035
- list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
2036
- list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
2080
+ list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row)return;
2081
+ if(e.target.closest('.chev')&&row.dataset.conn){treeExpanded.has(row.dataset.conn)?treeExpanded.delete(row.dataset.conn):treeExpanded.add(row.dataset.conn);renderPropPop();return;} // chevron → expand/collapse the connection node
2082
+ const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};
2083
+ if(row.dataset.conn){treeSelectConn(row.dataset.conn,mods);return;} // connection node → select the whole connection in the model
2084
+ if(row.dataset.tid){treeSelectLeaf(row.dataset.tid,mods);return;} // a part / member row → select it (Ctrl toggle, Shift range)
2085
+ if(row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // member label rows carry data-k
2086
+ list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row&&row.dataset.k)togglePropKey(row.dataset.k,cb.checked);});
2037
2087
  el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
2038
2088
  el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
2039
2089
  el.querySelector('#ppClose').onclick=()=>closePropPop(true);
@@ -2053,6 +2103,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
2053
2103
  refreshPropLabels();}
2054
2104
  // rebuild the popup contents against the current selection (chrome + rows), preserving row focus
2055
2105
  function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
2106
+ if(propPopPinned){propPopConn=selTree().conns.length?true:(selArr().length?null:propPopConn);} // a PINNED popup follows the selection: any connection involved → tree mode; pure members → label-picker
2107
+ el.classList.toggle('connmode',!!propPopConn);
2108
+ if(propPopConn){renderConnTree(el);return;} // connection / mixed-selection tree view
2056
2109
  const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2057
2110
  el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
2058
2111
  el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
@@ -2072,12 +2125,67 @@ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el
2072
2125
  if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
2073
2126
  function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
2074
2127
  el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
2075
- function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2128
+ function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2076
2129
  sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
2077
2130
  el.classList.add('open');renderPropPop();
2078
2131
  if(propPopPinned){dockPropPop();}
2079
2132
  else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
2080
2133
  setTimeout(()=>sr.focus(),0);}
2134
+ // ---- Connection read-only Properties popup (right-click a selected connection). Reuses #propPop in
2135
+ // `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
2136
+ // connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
2137
+ function connPropRows(cs){
2138
+ const j=cs.joint,isBP=cs.kind==='base-plate',pp=(j&&j.params)||{}; // tolerate a missing joint (orphaned part) rather than throw
2139
+ const plate=(partsById||{})[cs.conn+':plate']||null;
2140
+ const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
2141
+ const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
2142
+ return [
2143
+ {label:'Type', value:isBP?'Base plate':'Shear plate'},
2144
+ {label:'On member', value:cs.main},
2145
+ {label:'Plate width', value:plate?dim(plate.width):'—'},
2146
+ {label:'Plate height', value:plate?dim(plate.depth):'—'},
2147
+ {label:'Plate thickness', value:plate?dim(plate.thickness):dim(pp[isBP?'thickness':'plateThickness'])},
2148
+ {label:'Weld leg', value:dim(pp.weldLeg||(isBP?8:6))},
2149
+ {label:isBP?'Anchor grid':'Bolt grid', value:cols+' × '+rows},
2150
+ {label:isBP?'Anchor diameter':'Bolt diameter', value:dim(pp.boltDia||(isBP?24:20))},
2151
+ {label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
2152
+ {label:'Parts', value:String(cs.childIds.length)},
2153
+ ];}
2154
+ function openConnPop(x,y){propPopConn=true;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2155
+ sr.value='';el.classList.add('open');renderPropPop();
2156
+ if(propPopPinned){dockPropPop();}
2157
+ else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
2158
+ setTimeout(()=>sr.focus(),0);}
2159
+ // Render the connection/tree view. Single connection → its property summary + an interactive parts list;
2160
+ // multiple connections and/or members → a tree of selectable rows (click = select in the model, Ctrl toggle,
2161
+ // Shift range). Re-derived from the live selection each call so it self-heals as the selection moves.
2162
+ function renderConnTree(el){
2163
+ const t=selTree();
2164
+ if(!t.conns.length){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
2165
+ el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
2166
+ el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
2167
+ el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
2168
+ const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2169
+ const single=t.conns.length===1&&!t.members.length;
2170
+ const kindName=k=>k==='base-plate'?'Base plate':k==='shear-plate'?'Shear plate':'Connection';
2171
+ const nSel=t.conns.length+t.members.length, rowMatch=s=>!q||String(s).toLowerCase().includes(q), sw='<span class=tsw></span>';
2172
+ el.querySelector('#ppTitle').textContent=single?('Properties · '+kindName(t.conns[0].kind)):('Properties · '+nSel+' objects');
2173
+ el.querySelector('#ppScope').textContent=single?('On '+t.conns[0].main):(t.conns.length+' connection'+(t.conns.length===1?'':'s')+(t.members.length?(' · '+t.members.length+' member'+(t.members.length===1?'':'s')):''));
2174
+ el.querySelector('#ppLabeled').textContent='';
2175
+ treeRowIds=[];let html='';
2176
+ if(single){ const all=connPropRows(t.conns[0]).filter(r=>rowMatch(r.label)); // property summary (read-only) …
2177
+ html+=all.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join('');
2178
+ html+=`<div class=divrow><hr><span class=sect style="margin:0">Parts (${t.conns[0].childIds.length})</span><hr></div>`; } // … then the interactive parts
2179
+ for(const c of t.conns){
2180
+ const expanded=single||treeExpanded.has(c.conn), wholeSel=c.childIds.length&&c.childIds.every(id=>selIds.has(id));
2181
+ if(!single) html+=`<div class="pprow trow node${wholeSel?' selrow':''}" data-conn="${esc(c.conn)}"><span class=chev>${expanded?'▾':'▸'}</span>${sw}<span class=pn>${esc(kindName(c.kind)+' · '+c.main)}</span><span class=cnt>${c.childIds.length} parts</span></div>`;
2182
+ if(expanded) for(const id of c.childIds){ const e2=(partsById||{})[id],lbl=(e2&&e2.meta&&e2.meta.label)||id.slice(id.indexOf(':')+1); if(!rowMatch(lbl))continue; treeRowIds.push(id);
2183
+ html+=`<div class="pprow trow ptrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
2184
+ }
2185
+ for(const id of t.members){ const m=byId(id),lbl=id+(m&&m.profile?(' · '+m.profile):''); if(!rowMatch(lbl))continue; treeRowIds.push(id);
2186
+ html+=`<div class="pprow trow mrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
2187
+ el.querySelector('#ppList').innerHTML=html||'<div class=ppempty>No properties match your search.</div>';
2188
+ el.querySelector('#ppMeta').textContent=single?(t.conns[0].childIds.length+' parts'):(nSel+' selected');}
2081
2189
  function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
2082
2190
  const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
2083
2191
  document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
@@ -2122,7 +2230,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
2122
2230
  document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
2123
2231
  if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
2124
2232
  if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
2125
- if(mode==='sel'&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;} // a selection → Properties popup
2233
+ if(mode==='sel'&&!cmTool&&!picking){
2234
+ if(selTree().conns.length){openConnPop(e.clientX,e.clientY);return;} // any connection in the selection → the connection / mixed-selection tree popup
2235
+ if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members only → the member label-picker popup
2126
2236
  });
2127
2237
  document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
2128
2238
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
@@ -30,10 +30,22 @@
30
30
  const $status = document.getElementById('ws-status');
31
31
  const $stepTabs = document.getElementById('ws-step-tabs');
32
32
  const $projMenu = document.getElementById('ws-proj-menu');
33
+ const $wsExports = document.getElementById('ws-exports');
33
34
  const frames = {
34
35
  model: document.getElementById('ws-frame-model'),
35
36
  drawings: document.getElementById('ws-frame-drawings'),
36
37
  };
38
+
39
+ // The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
40
+ // line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
41
+ // Tekla is an ACTION (host mutation, no file). `kind` matches the server's export route + listing.
42
+ const EXPORT_CARDS = [
43
+ { kind: 'bom-csv', ico: '▤', name: 'Bill of materials', fmt: 'CSV', verb: 'Export CSV', file: true, open: true },
44
+ { kind: 'bom-xlsx', ico: '▦', name: 'Bill of materials', fmt: 'XLSX', verb: 'Export Excel', file: true, open: true },
45
+ { kind: 'ifc', ico: '◈', name: 'Model', fmt: 'IFC4', verb: 'Export IFC', file: true, open: false },
46
+ { kind: 'tekla', ico: '◧', name: 'Tekla', fmt: '.ifc → Open API', verb: 'Send to Tekla', file: false },
47
+ ];
48
+ let exportState = null; // kind → { path, exportedAt } after a successful list; null = unknown/failed
37
49
  if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
38
50
 
39
51
  // The picker menu is a `.menu` (display:none by default; `.menu.show` → display:block, which
@@ -185,6 +197,10 @@
185
197
  f.hidden = !show;
186
198
  if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
187
199
  }
200
+ // Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
201
+ const showExports = s === 'exports';
202
+ $wsExports.hidden = !showExports;
203
+ if (showExports) renderExports();
188
204
  }
189
205
  $stepTabs.addEventListener('click', (e) => {
190
206
  const b = e.target.closest('button[data-step]');
@@ -192,7 +208,10 @@
192
208
  });
193
209
 
194
210
  // ── Approve (per-project) ───────────────────────────────────────────────────
195
- document.getElementById('ws-approve').addEventListener('click', async () => {
211
+ // Shared by the header ✓ Approve and the Exports-tab gate link ("Approve the model"), so the
212
+ // sign-off is one flow. On success it stamps current.approvedAt (server returns it) so the
213
+ // Exports gate unlocks without a reload.
214
+ async function approveProject() {
196
215
  if (!current) return;
197
216
  const btn = document.getElementById('ws-approve');
198
217
  btn.disabled = true;
@@ -204,11 +223,141 @@
204
223
  // fires. flushContract() PUTs the live contract and resolves; a save failure aborts the bake.
205
224
  const win = frames.model && frames.model.contentWindow;
206
225
  if (win && typeof win.flushContract === 'function') await win.flushContract();
207
- await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
226
+ const res = await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
227
+ if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
208
228
  showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
229
+ if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
209
230
  } catch (err) {
210
231
  showToast('Approve failed: ' + (err && err.message || err), 'warn');
211
232
  } finally { btn.disabled = false; btn.textContent = prev; }
233
+ }
234
+ document.getElementById('ws-approve').addEventListener('click', approveProject);
235
+
236
+ // ── Exports step ──────────────────────────────────────────────────────────────
237
+ const hhmm = (iso) => {
238
+ const d = new Date(iso);
239
+ if (isNaN(d.getTime())) return '';
240
+ return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
241
+ };
242
+
243
+ // The POST export endpoint for a card kind, project-scoped so it reads THIS project's contract
244
+ // and writes into its own exports folder.
245
+ function exportUrl(kind) {
246
+ const qs = `?project=${encodeURIComponent(current.id)}`;
247
+ const base = `/api/contract/${encodeURIComponent(current.app)}`;
248
+ if (kind === 'bom-csv') return `${base}/export-bom/csv${qs}`;
249
+ if (kind === 'bom-xlsx') return `${base}/export-bom/xlsx${qs}`;
250
+ if (kind === 'ifc') return `${base}/export-ifc${qs}`;
251
+ return `${base}/export-tekla${qs}`; // tekla
252
+ }
253
+
254
+ function cardHtml(c, approved) {
255
+ let meta;
256
+ if (!approved) {
257
+ meta = `<span class="e-status">Available after approval</span>`;
258
+ } else if (!c.file) {
259
+ meta = `<span class="e-status">Creates parts in the open Tekla model</span>`;
260
+ } else if (exportState == null) {
261
+ // The listing failed — say so honestly; never a fake "not exported yet" (silent-failure rule).
262
+ meta = `<span class="e-status">Status unavailable</span>`;
263
+ } else {
264
+ const st = exportState[c.kind];
265
+ if (st && st.exportedAt) {
266
+ const links = `<span class="e-links">` +
267
+ (c.open ? `<button type="button" class="e-link" data-openfile="${escapeAttr(c.kind)}" data-tip="Open in its default app">⧉ Open</button>` : '') +
268
+ `<button type="button" class="e-link" data-reveal="${escapeAttr(c.kind)}" data-tip="Show the file in its folder">▤ Reveal</button>` +
269
+ `</span>`;
270
+ meta = `<span class="e-done"><span class="chk">✓</span> exported <span class="t">${escapeHtml(hhmm(st.exportedAt))}</span></span>${links}`;
271
+ } else {
272
+ meta = `<span class="e-status">not exported yet</span>`;
273
+ }
274
+ }
275
+ const exported = approved && c.file && exportState && exportState[c.kind] && exportState[c.kind].exportedAt;
276
+ const label = exported ? 'Re-export' : c.verb;
277
+ const dis = approved ? '' : ' disabled data-tip="Approve the model first"';
278
+ return `<div class="ecard">` +
279
+ `<div class="e-top"><div class="e-ico">${escapeHtml(c.ico)}</div>` +
280
+ `<div><div class="e-name">${escapeHtml(c.name)}</div><div class="e-fmt">${escapeHtml(c.fmt)}</div></div></div>` +
281
+ `<div class="e-meta">${meta}</div>` +
282
+ `<div class="e-act"><button type="button" data-export="${escapeAttr(c.kind)}"${dis}>↗ ${escapeHtml(label)}</button></div>` +
283
+ `</div>`;
284
+ }
285
+
286
+ async function renderExports() {
287
+ if (!current) return;
288
+ const id = current.id, app = current.app;
289
+ $wsExports.innerHTML = '<div class="exports-note">Loading exports…</div>'; // avoid a blank flash
290
+ let state = null;
291
+ let approvedAt = current.approvedAt; // fallback if the fetch fails (best-known)
292
+ try {
293
+ const r = await api(`/api/contract/${encodeURIComponent(app)}/exports?project=${encodeURIComponent(id)}`);
294
+ state = {};
295
+ for (const e of r.exports || []) state[e.kind] = e;
296
+ // Authoritative gate state: an in-session edit clears approvedAt server-side, so trust the
297
+ // server, not the possibly-stale current.approvedAt — and re-sync current to match.
298
+ approvedAt = r.approvedAt || undefined;
299
+ current.approvedAt = approvedAt;
300
+ } catch (err) {
301
+ // Surface the load failure — don't let it read as "nothing exported yet" (a lie).
302
+ showToast('Couldn’t load export status: ' + (err && err.message || err), 'warn');
303
+ state = null;
304
+ }
305
+ // The user may have switched project/step during the await — don't paint stale.
306
+ if (!current || current.id !== id || $wsExports.hidden) return;
307
+ exportState = state;
308
+ const approved = !!approvedAt;
309
+ let html = '';
310
+ if (!approved) {
311
+ html += `<div class="ws-exports-gate">Exports go out after sign-off — ` +
312
+ `<button type="button" id="ws-exports-approve">Approve the model</button> to enable them.</div>`;
313
+ }
314
+ html += `<div class="exports-note"><b>Export at any stage</b> — polishing is optional. ` +
315
+ `Each writes to this project’s <code>exports</code> folder.</div>`;
316
+ html += '<div class="export-grid">' + EXPORT_CARDS.map((c) => cardHtml(c, approved)).join('') + '</div>';
317
+ $wsExports.innerHTML = html;
318
+ }
319
+
320
+ async function doExport(kind) {
321
+ if (!current) return;
322
+ const btn = $wsExports.querySelector(`[data-export="${kind}"]`);
323
+ const prev = btn ? btn.textContent : '';
324
+ if (btn) { btn.disabled = true; btn.textContent = kind === 'tekla' ? 'Baking…' : 'Exporting…'; }
325
+ try {
326
+ const res = await api(exportUrl(kind), { method: 'POST' });
327
+ if (kind === 'tekla') {
328
+ const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
329
+ showToast((res.message || 'Baked into the Tekla model.') + (dropped ? ` · ${dropped} RFI member(s) skipped` : ''), 'ok');
330
+ } else {
331
+ showToast(`Exported — ${res.filename || kind}`, 'ok');
332
+ return renderExports(); // repaint with the new "✓ exported HH:MM" + Open/Reveal
333
+ }
334
+ } catch (err) {
335
+ const needsTekla = err && err.body && err.body.needsTekla;
336
+ showToast(err && err.message ? err.message : String(err), needsTekla ? 'info' : 'warn');
337
+ } finally {
338
+ if (btn && btn.isConnected) { btn.disabled = false; btn.textContent = prev; }
339
+ }
340
+ }
341
+
342
+ async function fileAction(endpoint, kind, failMsg) {
343
+ const st = exportState && exportState[kind];
344
+ if (!st || !st.path) return; // button only shows when a path exists; belt-and-braces
345
+ try {
346
+ await api(endpoint, { method: 'POST', body: JSON.stringify({ path: st.path }) });
347
+ } catch (err) {
348
+ const detail = err && err.body && err.body.error;
349
+ showToast(detail ? `${failMsg} — ${detail}` : failMsg, 'warn');
350
+ }
351
+ }
352
+
353
+ $wsExports.addEventListener('click', (e) => {
354
+ if (e.target.closest('#ws-exports-approve')) { approveProject(); return; }
355
+ const exp = e.target.closest('[data-export]');
356
+ if (exp && !exp.disabled) { doExport(exp.dataset.export); return; }
357
+ const open = e.target.closest('[data-openfile]');
358
+ if (open) { fileAction('/api/open-file', open.dataset.openfile, 'Couldn’t open the file'); return; }
359
+ const rev = e.target.closest('[data-reveal]');
360
+ if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
212
361
  });
213
362
 
214
363
  // ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.72.2",
3
+ "version": "0.73.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": {