@floless/app 0.72.2 → 0.72.3

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.72.3" : 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.72.3" : 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,10 @@
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 */
352
356
  /* thin cluster divider in the 3D toolbar */
353
357
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
354
358
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -2018,6 +2022,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
2018
2022
 
2019
2023
  // ---- The floating Properties popup ----
2020
2024
  let propPopPinned=false;
2025
+ let propPopConn=null; // non-null = read-only Connection mode (a connection selected → connPropRows); null = the member label-picker mode
2021
2026
  function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
2022
2027
  function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2023
2028
  el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
@@ -2032,8 +2037,8 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2032
2037
  +`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
2033
2038
  document.body.appendChild(el);
2034
2039
  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);});
2040
+ list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // conn-mode rows carry no data-k → not toggleable
2041
+ 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
2042
  el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
2038
2043
  el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
2039
2044
  el.querySelector('#ppClose').onclick=()=>closePropPop(true);
@@ -2053,6 +2058,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
2053
2058
  refreshPropLabels();}
2054
2059
  // rebuild the popup contents against the current selection (chrome + rows), preserving row focus
2055
2060
  function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
2061
+ if(propPopPinned){const cs=connSelInfo();if(cs)propPopConn=cs;else if(selArr().length)propPopConn=null;} // a PINNED popup follows the selection across member ↔ connection (an unpinned one re-asserts its mode on each right-click)
2062
+ el.classList.toggle('connmode',!!propPopConn);
2063
+ if(propPopConn){renderConnProps(el);return;} // read-only Connection view
2056
2064
  const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2057
2065
  el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
2058
2066
  el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
@@ -2072,12 +2080,52 @@ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el
2072
2080
  if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
2073
2081
  function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
2074
2082
  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');
2083
+ function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2076
2084
  sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
2077
2085
  el.classList.add('open');renderPropPop();
2078
2086
  if(propPopPinned){dockPropPop();}
2079
2087
  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
2088
  setTimeout(()=>sr.focus(),0);}
2089
+ // ---- Connection read-only Properties popup (right-click a selected connection). Reuses #propPop in
2090
+ // `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
2091
+ // connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
2092
+ function connPropRows(cs){
2093
+ const j=cs.joint,isBP=cs.kind==='base-plate',pp=j.params||{};
2094
+ const plate=(partsById||{})[cs.conn+':plate']||null;
2095
+ const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
2096
+ const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
2097
+ return [
2098
+ {label:'Type', value:isBP?'Base plate':'Shear plate'},
2099
+ {label:'On member', value:cs.main},
2100
+ {label:'Plate width', value:plate?dim(plate.width):'—'},
2101
+ {label:'Plate height', value:plate?dim(plate.depth):'—'},
2102
+ {label:'Plate thickness', value:plate?dim(plate.thickness):dim(pp[isBP?'thickness':'plateThickness'])},
2103
+ {label:'Weld leg', value:dim(pp.weldLeg||(isBP?8:6))},
2104
+ {label:isBP?'Anchor grid':'Bolt grid', value:cols+' × '+rows},
2105
+ {label:isBP?'Anchor diameter':'Bolt diameter', value:dim(pp.boltDia||(isBP?24:20))},
2106
+ {label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
2107
+ {label:'Parts', value:String(cs.childIds.length)},
2108
+ ];}
2109
+ function openConnProps(x,y,cs){propPopConn=cs;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2110
+ sr.value='';el.classList.add('open');renderPropPop();
2111
+ if(propPopPinned){dockPropPop();}
2112
+ 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';}
2113
+ setTimeout(()=>sr.focus(),0);}
2114
+ // render the connection read-view; re-derives the connection each call so it self-heals if the selection moves
2115
+ function renderConnProps(el){
2116
+ const cs=connSelInfo();
2117
+ if(!cs){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
2118
+ el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
2119
+ el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
2120
+ el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
2121
+ propPopConn=cs;
2122
+ const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2123
+ el.querySelector('#ppTitle').textContent='Properties · '+(cs.kind==='base-plate'?'Base plate':'Shear plate');
2124
+ el.querySelector('#ppLabeled').textContent='';
2125
+ el.querySelector('#ppScope').textContent='On '+cs.main;
2126
+ const all=connPropRows(cs),rows=all.filter(r=>!q||r.label.toLowerCase().includes(q));
2127
+ el.querySelector('#ppList').innerHTML=rows.length?rows.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join(''):'<div class=ppempty>No properties match your search.</div>';
2128
+ el.querySelector('#ppMeta').textContent=rows.length+' of '+all.length+' shown';}
2081
2129
  function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
2082
2130
  const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
2083
2131
  document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
@@ -2122,7 +2170,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
2122
2170
  document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
2123
2171
  if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
2124
2172
  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
2173
+ if(mode==='sel'&&!cmTool&&!picking){const cs=connSelInfo();
2174
+ if(cs){openConnProps(e.clientX,e.clientY,cs);return;} // a connection selected → its read-only Properties popup
2175
+ if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members selected → the member label-picker popup
2126
2176
  });
2127
2177
  document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
2128
2178
  // --- 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.72.3",
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": {