@floless/app 0.19.0 → 0.21.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.
@@ -50958,6 +50958,17 @@ function appProvider(id) {
50958
50958
  return "other";
50959
50959
  }
50960
50960
  }
50961
+ function appBaked(id) {
50962
+ try {
50963
+ const dir = appDir(id);
50964
+ const sourcePath = firstWithExt(dir, ".app") ?? firstWithExt(dir, ".flo");
50965
+ if (!sourcePath) return false;
50966
+ const src = asRecord((0, import_yaml.parse)((0, import_node_fs3.readFileSync)(sourcePath, "utf8")));
50967
+ return src["exposes-as-agent"] === true;
50968
+ } catch {
50969
+ return false;
50970
+ }
50971
+ }
50961
50972
  function readApp(id) {
50962
50973
  const dir = appDir(id);
50963
50974
  const sourcePath = firstWithExt(dir, ".app") ?? firstWithExt(dir, ".flo");
@@ -51933,6 +51944,41 @@ var aware = {
51933
51944
  if (code !== 0) throw new AwareError(`aware app uninstall failed (exit ${code})`, { stdout, stderr });
51934
51945
  return { output: stdout.trim() };
51935
51946
  },
51947
+ /**
51948
+ * Rename an installed app in place (`aware app rename <old> <new>`, CLI ≥ 0.68).
51949
+ * AWARE owns the whole move — the app dir, the source `app:` field, the
51950
+ * regenerated lock (so the Run gate stays green, no drift), and the synthesized
51951
+ * agent of a baked (`exposes-as-agent`) app. `compiled` reflects whether AWARE
51952
+ * refreshed the lock (false → the app needs a manual Compile). Throws
51953
+ * AwareUnsupportedError on a pre-0.68 CLI so app-lifecycle can say "update AWARE"
51954
+ * instead of treating an old CLI as a hard fault.
51955
+ */
51956
+ async rename(oldId, newId) {
51957
+ const { code, stdout, stderr } = await runRaw(["app", "rename", assertId(oldId), assertId(newId)]);
51958
+ if (code !== 0) {
51959
+ if (isInvokeUnsupported({ stdout, stderr })) {
51960
+ throw new AwareUnsupportedError("aware CLI does not support `app rename` (needs \u2265 0.68) \u2014 update AWARE", { stdout, stderr });
51961
+ }
51962
+ throw new AwareError(`aware app rename ${oldId} \u2192 ${newId} failed (exit ${code})`, { stdout, stderr, code });
51963
+ }
51964
+ return { compiled: /lock refreshed/i.test(stdout), output: stdout.trim() };
51965
+ },
51966
+ /**
51967
+ * Duplicate an installed app into an independent copy (`aware app duplicate
51968
+ * <src> <new>`, CLI ≥ 0.68). The original is untouched; a baked copy gets its
51969
+ * own synthesized agent. Same `compiled` + unsupported-degradation contract as
51970
+ * rename.
51971
+ */
51972
+ async duplicate(srcId, newId) {
51973
+ const { code, stdout, stderr } = await runRaw(["app", "duplicate", assertId(srcId), assertId(newId)]);
51974
+ if (code !== 0) {
51975
+ if (isInvokeUnsupported({ stdout, stderr })) {
51976
+ throw new AwareUnsupportedError("aware CLI does not support `app duplicate` (needs \u2265 0.68) \u2014 update AWARE", { stdout, stderr });
51977
+ }
51978
+ throw new AwareError(`aware app duplicate ${srcId} \u2192 ${newId} failed (exit ${code})`, { stdout, stderr, code });
51979
+ }
51980
+ return { compiled: /lock refreshed/i.test(stdout), output: stdout.trim() };
51981
+ },
51936
51982
  /**
51937
51983
  * Install a first-party agent by registry id (`aware agent install <id>`). The
51938
51984
  * registry install pulls a large tarball (~200s observed) — far over runRaw's 60s
@@ -52683,7 +52729,7 @@ function appVersion() {
52683
52729
  return resolveVersion({
52684
52730
  isSea: isSea2(),
52685
52731
  sqVersionXml: readSqVersionXml(),
52686
- define: true ? "0.19.0" : void 0,
52732
+ define: true ? "0.21.0" : void 0,
52687
52733
  pkgVersion: readPkgVersion()
52688
52734
  });
52689
52735
  }
@@ -52693,7 +52739,7 @@ function resolveChannel(s) {
52693
52739
  return "dev";
52694
52740
  }
52695
52741
  function appChannel() {
52696
- return resolveChannel({ isSea: isSea2(), define: true ? "0.19.0" : void 0 });
52742
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.21.0" : void 0 });
52697
52743
  }
52698
52744
 
52699
52745
  // oauth-presets.ts
@@ -54104,49 +54150,15 @@ function appDirPath(id) {
54104
54150
  function appInstalled(id) {
54105
54151
  return APP_ID3.test(id) && (0, import_node_fs16.existsSync)(appDirPath(id));
54106
54152
  }
54107
- function findSource(dir) {
54108
- const files = (0, import_node_fs16.readdirSync)(dir);
54109
- const hit = files.find((f) => f.toLowerCase().endsWith(".app")) ?? files.find((f) => f.toLowerCase().endsWith(".flo"));
54110
- return hit ? (0, import_node_path14.join)(dir, hit) : null;
54111
- }
54112
- function rewriteAppId(text, newId) {
54113
- const re = /^(app:[ \t]*)(["']?)([^"'#\r\n]+?)\2([ \t]*(?:#[^\r\n]*)?)$/m;
54114
- if (!re.test(text)) throw new AppLifecycleError("source has no top-level app: field", 422);
54115
- return text.replace(re, (_m, pre, quote, _id, trail) => `${pre}${quote}${newId}${quote}${trail}`);
54116
- }
54117
- function isExposedAsAgent(dir) {
54118
- const src = findSource(dir);
54119
- return src ? /^exposes-as-agent:[ \t]*true\b/m.test((0, import_node_fs16.readFileSync)(src, "utf8")) : false;
54120
- }
54121
54153
  function logCascade(what, e) {
54122
54154
  console.error(`[app-lifecycle] cascade step failed (${what}):`, e instanceof Error ? e.message : e);
54123
54155
  }
54124
- function removeLocks(dir) {
54125
- for (const f of (0, import_node_fs16.readdirSync)(dir)) {
54126
- const lower = f.toLowerCase();
54127
- if (lower.endsWith(".lock") || lower === "lockfile.yaml") {
54128
- try {
54129
- (0, import_node_fs16.unlinkSync)((0, import_node_path14.join)(dir, f));
54130
- } catch {
54131
- }
54132
- }
54133
- }
54134
- }
54135
- function restampSource(dir, newId) {
54136
- const src = findSource(dir);
54137
- if (!src) throw new AppLifecycleError("app has no source file", 422);
54138
- const text = rewriteAppId((0, import_node_fs16.readFileSync)(src, "utf8"), newId);
54139
- const ext = src.toLowerCase().endsWith(".flo") ? ".flo" : ".app";
54140
- const newSrc = (0, import_node_path14.join)(dir, `${newId}${ext}`);
54141
- (0, import_node_fs16.writeFileSync)(newSrc, text);
54142
- if (src !== newSrc) {
54143
- try {
54144
- (0, import_node_fs16.unlinkSync)(src);
54145
- } catch {
54146
- }
54147
- }
54148
- removeLocks(dir);
54149
- return newSrc;
54156
+ function mapAwareError(e) {
54157
+ const code = e instanceof AwareError ? e.detail?.code : void 0;
54158
+ if (code === 8) throw new AppLifecycleError("a workflow with that name already exists", 409);
54159
+ if (code === 7) throw new AppLifecycleError("workflow not found", 404);
54160
+ if (code === 3) throw new AppLifecycleError(e instanceof Error ? e.message : "invalid name", 400);
54161
+ throw e;
54150
54162
  }
54151
54163
  function assertNewId(newId) {
54152
54164
  if (!newId || !APP_ID3.test(newId) || !safeSegment(newId)) {
@@ -54154,39 +54166,16 @@ function assertNewId(newId) {
54154
54166
  }
54155
54167
  }
54156
54168
  var defaultDeps2 = {
54157
- compile: (p) => aware.compile(p),
54169
+ rename: (oldId, newId) => aware.rename(oldId, newId),
54170
+ duplicate: (srcId, newId) => aware.duplicate(srcId, newId),
54158
54171
  uninstall: (id) => aware.uninstall(id)
54159
54172
  };
54160
- async function tryCompile(deps, sourcePath) {
54161
- try {
54162
- await deps.compile(sourcePath);
54163
- return true;
54164
- } catch {
54165
- return false;
54166
- }
54167
- }
54168
54173
  async function renameApp(oldId, newId, deps = defaultDeps2) {
54169
54174
  if (!appInstalled(oldId)) throw new AppLifecycleError(`workflow not found: ${oldId}`, 404);
54170
54175
  assertNewId(newId);
54171
54176
  if (newId === oldId) throw new AppLifecycleError("that is already its name", 400);
54172
54177
  if (appInstalled(newId)) throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
54173
- if (isExposedAsAgent(appDirPath(oldId))) {
54174
- throw new AppLifecycleError("this workflow is baked into an agent \u2014 rename isn\u2019t supported yet (the agent wouldn\u2019t follow). Un-bake it first, or rename it from the terminal.", 409);
54175
- }
54176
- const oldDir = appDirPath(oldId);
54177
- const newDir = appDirPath(newId);
54178
- (0, import_node_fs16.renameSync)(oldDir, newDir);
54179
- let newSrc;
54180
- try {
54181
- newSrc = restampSource(newDir, newId);
54182
- } catch (e) {
54183
- try {
54184
- (0, import_node_fs16.renameSync)(newDir, oldDir);
54185
- } catch {
54186
- }
54187
- throw e;
54188
- }
54189
- const compiled = await tryCompile(deps, newSrc);
54178
+ const { compiled } = await deps.rename(oldId, newId).catch(mapAwareError);
54190
54179
  try {
54191
54180
  renameVisualInputs(oldId, newId);
54192
54181
  } catch (e) {
@@ -54203,27 +54192,7 @@ async function duplicateApp(srcId, newId, deps = defaultDeps2) {
54203
54192
  if (!appInstalled(srcId)) throw new AppLifecycleError(`workflow not found: ${srcId}`, 404);
54204
54193
  assertNewId(newId);
54205
54194
  if (appInstalled(newId)) throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
54206
- if (isExposedAsAgent(appDirPath(srcId))) {
54207
- throw new AppLifecycleError("this workflow is baked into an agent \u2014 duplicate isn\u2019t supported yet (the copy\u2019s agent wouldn\u2019t be installed). Un-bake it first, or copy it from the terminal.", 409);
54208
- }
54209
- const newDir = appDirPath(newId);
54210
- try {
54211
- (0, import_node_fs16.mkdirSync)(newDir);
54212
- } catch {
54213
- throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
54214
- }
54215
- let newSrc;
54216
- try {
54217
- (0, import_node_fs16.cpSync)(appDirPath(srcId), newDir, { recursive: true });
54218
- newSrc = restampSource(newDir, newId);
54219
- } catch (e) {
54220
- try {
54221
- (0, import_node_fs16.rmSync)(newDir, { recursive: true, force: true });
54222
- } catch {
54223
- }
54224
- throw e;
54225
- }
54226
- const compiled = await tryCompile(deps, newSrc);
54195
+ const { compiled } = await deps.duplicate(srcId, newId).catch(mapAwareError);
54227
54196
  try {
54228
54197
  copyVisualInputs(srcId, newId);
54229
54198
  } catch (e) {
@@ -54233,7 +54202,7 @@ async function duplicateApp(srcId, newId, deps = defaultDeps2) {
54233
54202
  }
54234
54203
  async function deleteApp(id, deps = defaultDeps2) {
54235
54204
  if (!appInstalled(id)) throw new AppLifecycleError(`workflow not found: ${id}`, 404);
54236
- await deps.uninstall(id);
54205
+ await deps.uninstall(id).catch(mapAwareError);
54237
54206
  try {
54238
54207
  deleteVisualInputs(id);
54239
54208
  } catch (e) {
@@ -57273,7 +57242,7 @@ async function startServer() {
57273
57242
  }
57274
57243
  });
57275
57244
  await app.register(import_static.default, { root: WEB_ROOT, prefix: "/" });
57276
- const MIN_AWARE = "0.51.0";
57245
+ const MIN_AWARE = "0.68.0";
57277
57246
  const isWin3 = process.platform === "win32";
57278
57247
  const has = (cmd) => {
57279
57248
  try {
@@ -57499,7 +57468,7 @@ async function startServer() {
57499
57468
  onSeatLost((reason) => broadcast({ type: "seat-taken", reason }));
57500
57469
  app.get("/api/apps", async () => {
57501
57470
  const apps = await aware.list();
57502
- return { ok: true, apps: apps.map((a) => ({ ...a, provider: appProvider(a.id) })) };
57471
+ return { ok: true, apps: apps.map((a) => ({ ...a, provider: appProvider(a.id), baked: appBaked(a.id) })) };
57503
57472
  });
57504
57473
  app.get("/api/agents", async () => {
57505
57474
  const agents = await aware.agentList();
@@ -57810,7 +57779,16 @@ async function startServer() {
57810
57779
  broadcast({ type: "run-started", id, dryRun: !!dryRun, simulate: !!simulate, runId: result.runId });
57811
57780
  for (const ev of events) broadcast({ type: "trace", id, event: ev });
57812
57781
  broadcast({ type: "run-ended", id, runId: result.runId });
57813
- const report = withBadge(extractReportHtml(events));
57782
+ const extracted = extractReportHtml(events);
57783
+ let report = null;
57784
+ if (extracted) {
57785
+ let interactive = false;
57786
+ try {
57787
+ interactive = readApp(id).nodes.find((n) => n.id === extracted.nodeId)?.agent === "viewer-3d";
57788
+ } catch {
57789
+ }
57790
+ report = { ...interactive ? extracted : withBadge(extracted), interactive };
57791
+ }
57814
57792
  const credentialIssue = !simulate && hasAuthFailure(events) ? await detectCredentialIssue(id) : null;
57815
57793
  return { ok: true, runId: result.runId, tracePath: result.tracePath, events, report, credentialIssue };
57816
57794
  }
package/dist/web/app.css CHANGED
@@ -2209,7 +2209,9 @@ body {
2209
2209
  .report-actions button.report-stop:hover { background: color-mix(in srgb, var(--warn) 16%, transparent); border-color: var(--warn); color: var(--text); }
2210
2210
  #report-close { font-size: 18px; line-height: 1; padding: 4px 10px; }
2211
2211
  .report-stage { position: relative; flex: 1; background: #0f172a; }
2212
- #report-frame { width: 100%; height: 100%; border: 0; display: block; background: #0f172a; }
2212
+ /* Only the SHOWN frame fills the stage `:not([hidden])` so `display:block` never defeats the
2213
+ `hidden` attribute on the inactive frame (else it keeps its height and pushes the other off-screen). */
2214
+ #report-frame:not([hidden]), #report-frame-3d:not([hidden]) { width: 100%; height: 100%; border: 0; display: block; background: #0f172a; }
2213
2215
 
2214
2216
  /* Live "On trigger" report: a pulsing pip in the viewer subtitle + a brief edge
2215
2217
  flash on the stage when a trigger routine (e.g. tekla-selection-report) pushes a
package/dist/web/aware.js CHANGED
@@ -18,6 +18,7 @@
18
18
  const $notesStrip = document.getElementById('notes-strip');
19
19
  const $reportModal = document.getElementById('report-modal');
20
20
  const $reportFrame = document.getElementById('report-frame');
21
+ const $reportFrame3d = document.getElementById('report-frame-3d');
21
22
  const $reportOverlay = document.getElementById('report-overlay');
22
23
  const $reportTitle = document.getElementById('report-title');
23
24
  const $reportSub = document.getElementById('report-sub');
@@ -582,7 +583,7 @@
582
583
  // <button>s without nesting interactive elements (#85). Selection stays driven by
583
584
  // the delegated $wfList click + comboMove highlight (aria-activedescendant).
584
585
  const aid = escapeAttr(a.id);
585
- html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
586
+ html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-baked="${a.baked ? '1' : ''}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
586
587
  + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
587
588
  + `<span class="wf-option-actions">`
588
589
  + `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
@@ -708,7 +709,7 @@
708
709
  const act = e.target.closest('[data-wf-act]');
709
710
  if (act) {
710
711
  const id = act.dataset.id;
711
- if (act.dataset.wfAct === 'rename') openRenameWorkflow(id);
712
+ if (act.dataset.wfAct === 'rename') openRenameWorkflow(id, act.closest('.wf-option')?.dataset.baked === '1');
712
713
  else if (act.dataset.wfAct === 'duplicate') openDuplicateWorkflow(id);
713
714
  else if (act.dataset.wfAct === 'delete') confirmDeleteWorkflow(id);
714
715
  return;
@@ -737,11 +738,17 @@
737
738
  appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
738
739
  }
739
740
 
740
- async function openRenameWorkflow(oldId) {
741
+ async function openRenameWorkflow(oldId, baked = false) {
741
742
  closeCombo(false);
743
+ // A baked workflow is a reusable agent; renaming it also renames that agent, so any
744
+ // OTHER workflow that uses it as a node will reference the old name and need updating.
745
+ // Warn (informed consent) — not a blocker; the user may be renaming an unreferenced
746
+ // one (#226 UX review).
747
+ const sub = 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.'
748
+ + (baked ? ' Renaming a baked workflow also renames its synthesized agent — other workflows that use it as a node will need updating.' : '');
742
749
  const res = await formModal({
743
750
  title: 'Rename workflow',
744
- sub: 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.',
751
+ sub,
745
752
  fields: [{ name: 'name', label: 'New name', type: 'text', value: oldId, placeholder: 'e.g. tekla-bom-by-phase' }],
746
753
  okLabel: 'Rename',
747
754
  });
@@ -838,10 +845,10 @@
838
845
  function reportNodeId() {
839
846
  const app = currentId && apps.get(currentId);
840
847
  if (!app || !app.nodes.length) return null;
841
- // An html-report agent node renders report HTML — its `render` command returns
842
- // an `html` field, which is exactly what extractReportHtml keys on. So it's a
843
- // report producer regardless of exec code; prefer it directly as the viewer node.
844
- const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report');
848
+ // An html-report (static) or viewer-3d (interactive 3D) agent node renders HTML — its
849
+ // `render` command returns an `html` field, exactly what extractReportHtml keys on. So
850
+ // it's a report producer regardless of exec code; prefer it directly as the viewer node.
851
+ const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report' || n.agent === 'viewer-3d');
845
852
  if (htmlReportNode) return htmlReportNode.id;
846
853
  // Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
847
854
  // code returns an `html` field (the `data.result.html` extractReportHtml keys
@@ -953,7 +960,11 @@
953
960
  // clear anything we injected last render, so re-renders never stack
954
961
  card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
955
962
  if (isInput) addNodeInputs(card); // current values, above the button
956
- if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
963
+ if (isReport) {
964
+ const rNode = app && app.nodes.find((n) => n.id === rid);
965
+ const viewLabel = rNode && rNode.agent === 'viewer-3d' ? 'View 3D ▸' : 'View report ▸';
966
+ addNodeAction(card, viewLabel, () => showReport(id));
967
+ }
957
968
  if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
958
969
  if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
959
970
  });
@@ -1245,7 +1256,37 @@
1245
1256
 
1246
1257
  // Share is visible exactly when the frame holds a report (the empty state HIDES it —
1247
1258
  // never a disabled affordance with no path forward).
1248
- function paintReport(html) { $reportFrame.srcdoc = html; $reportOverlay.hidden = true; $reportOverlay.replaceChildren(); $reportShare.hidden = false; }
1259
+ // The last HTML painted (static or 3D) so Open works for both surfaces.
1260
+ let lastPaintedHtml = '';
1261
+ // Paint a report into the viewer. A STATIC report uses the scriptless srcdoc frame; an
1262
+ // INTERACTIVE 3D doc (viewer-3d) uses the script-enabled frame via a data: URL (opaque
1263
+ // origin, cannot touch the app page) and keeps a "Loading 3D view…" overlay until the doc
1264
+ // posts `viewer-ready`. Exactly one frame is shown at a time.
1265
+ function paintReport(html, interactive) {
1266
+ lastPaintedHtml = html || '';
1267
+ if (interactive) {
1268
+ $reportFrame.hidden = true; $reportFrame.srcdoc = '';
1269
+ $reportFrame3d.hidden = false;
1270
+ $reportFrame3d.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
1271
+ $reportShare.hidden = true; // a script doc can't be statically hosted — hide, don't disable
1272
+ $reportOverlay.hidden = false; $reportOverlay.replaceChildren();
1273
+ $reportOverlay.append(mkEl('div', 'spinner'), mkEl('div', null, 'Loading 3D view…'));
1274
+ } else {
1275
+ $reportFrame3d.hidden = true; $reportFrame3d.removeAttribute('src');
1276
+ $reportFrame.hidden = false; $reportFrame.srcdoc = html;
1277
+ $reportOverlay.hidden = true; $reportOverlay.replaceChildren();
1278
+ $reportShare.hidden = false;
1279
+ }
1280
+ }
1281
+
1282
+ // Reset the viewer to "no report shown": clear BOTH frames + the Open buffer, so a stale
1283
+ // interactive 3D doc can't linger (its WebGL loop running) behind an overlay, and ↗ Open
1284
+ // can't reopen a previous app's report. Used by every empty / transition state.
1285
+ function clearReportFrames() {
1286
+ $reportFrame.srcdoc = ''; $reportFrame.hidden = false;
1287
+ $reportFrame3d.hidden = true; $reportFrame3d.removeAttribute('src');
1288
+ lastPaintedHtml = '';
1289
+ }
1249
1290
 
1250
1291
  // Double-click the HTML Viewer node → LOAD the last report (no run; running is
1251
1292
  // the header "▶ Run workflow" button's job). Shows a prompt if nothing has run yet.
@@ -1258,10 +1299,16 @@
1258
1299
  if (reportRunning) return; // a run is in flight — its overlay already shows progress
1259
1300
  const cached = lastReportByApp.get(currentId);
1260
1301
  if (cached && cached.html) {
1261
- $reportSub.innerHTML = `Last report${cached.label ? ' · ' + escapeHtml(cached.label) : ''} — rendered from the run's output, never composed by the UI.`;
1262
- paintReport(cached.html);
1302
+ const sfx = cached.label ? ' · ' + cached.label : '';
1303
+ if (cached.interactive) {
1304
+ $reportTitle.textContent = `3D Viewer · ${app.displayName}`;
1305
+ $reportSub.textContent = `Last 3D view${sfx} — drag to orbit, scroll to zoom, click an element to inspect.`;
1306
+ } else {
1307
+ $reportSub.textContent = `Last report${sfx} — rendered from the run's output, never composed by the UI.`;
1308
+ }
1309
+ paintReport(cached.html, cached.interactive);
1263
1310
  } else {
1264
- $reportFrame.srcdoc = '';
1311
+ clearReportFrames();
1265
1312
  $reportShare.hidden = true; // nothing to share — hide, don't disable
1266
1313
  $reportOverlay.hidden = false;
1267
1314
  $reportOverlay.innerHTML = `<div>No report yet. Click <strong>▶ Run workflow</strong> (top right) to run it against the live model, then double-click to view it any time.</div>`;
@@ -1286,7 +1333,7 @@
1286
1333
  const inputBadge = Object.entries(inputs).map(([k, v]) => `${k}=${v}`).join(' · ');
1287
1334
  $reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
1288
1335
  $reportSub.innerHTML = `Live run of <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — rendered from the node's output, never composed by the UI.`;
1289
- $reportFrame.srcdoc = ''; // clear any previously rendered report while this run is in flight
1336
+ clearReportFrames(); // clear any previous report (static or 3D) before this run paints a fresh one
1290
1337
  $reportShare.hidden = true; // the frame is blank — only a painted report is shareable
1291
1338
  $reportOverlay.hidden = false;
1292
1339
  $reportOverlay.innerHTML = opts.debug
@@ -1307,14 +1354,19 @@
1307
1354
  // body, not real data) — prompt reconnect instead of rendering the noise.
1308
1355
  if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
1309
1356
  const html = res.report && res.report.html;
1357
+ const interactive = !!(res.report && res.report.interactive);
1310
1358
  if (!html) {
1311
1359
  $reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
1312
1360
  return;
1313
1361
  }
1314
- paintReport(html);
1315
- lastReportByApp.set(currentId, { nodeId, label: inputBadge, html });
1362
+ if (interactive) {
1363
+ $reportTitle.textContent = `3D Viewer · ${app.displayName}`;
1364
+ $reportSub.innerHTML = `Live 3D view from <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — drag to orbit, scroll to zoom, click an element to inspect.`;
1365
+ }
1366
+ paintReport(html, interactive);
1367
+ lastReportByApp.set(currentId, { nodeId, label: inputBadge, html, interactive });
1316
1368
  $reportModal.dataset.html = '1';
1317
- appendNarration(`Rendered the report from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
1369
+ appendNarration(`Rendered the ${interactive ? '3D view' : 'report'} from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
1318
1370
  } catch (e) {
1319
1371
  const msg = e && e.message ? e.message : String(e);
1320
1372
  if (cancelRequested) {
@@ -1362,7 +1414,7 @@
1362
1414
  const CRED_BODY_IDLE = 'Sign in again and then run the workflow — it takes about 30 seconds.';
1363
1415
 
1364
1416
  function showCredentialReconnect(cred) {
1365
- $reportFrame.srcdoc = '';
1417
+ clearReportFrames();
1366
1418
  $reportShare.hidden = true;
1367
1419
  $reportOverlay.hidden = false;
1368
1420
  $reportOverlay.replaceChildren();
@@ -1674,10 +1726,22 @@
1674
1726
  // The Stop button is rebuilt into the overlay each run — delegate so one
1675
1727
  // listener survives every innerHTML swap.
1676
1728
  $reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
1729
+ // The interactive 3D doc (viewer-3d) posts a load handshake from its opaque-origin frame:
1730
+ // `viewer-ready` clears the loading overlay; `viewer-error` (CDN/script/timeout) shows a
1731
+ // retry. Guard: only when the 3D frame is the active surface and the message is from it.
1732
+ window.addEventListener('message', (e) => {
1733
+ if ($reportFrame3d.hidden || e.source !== $reportFrame3d.contentWindow) return;
1734
+ const t = e.data && e.data.type;
1735
+ if (t === 'viewer-ready') { $reportOverlay.hidden = true; $reportOverlay.replaceChildren(); }
1736
+ else if (t === 'viewer-error') {
1737
+ $reportOverlay.hidden = false; $reportOverlay.replaceChildren();
1738
+ $reportOverlay.append(mkEl('div', null, '3D view failed to load — check your connection, then click ▶ Run workflow to try again.'));
1739
+ }
1740
+ });
1677
1741
  // The header ■ Stop run — the always-reachable twin of the overlay Stop (#39).
1678
1742
  if ($stopRunBtn) $stopRunBtn.onclick = () => stopRun();
1679
1743
  $reportOpen.onclick = () => {
1680
- const html = $reportFrame.srcdoc;
1744
+ const html = lastPaintedHtml || $reportFrame.srcdoc;
1681
1745
  if (!html) return;
1682
1746
  // Open via a blob: URL — an opaque origin, so the report can't script the
1683
1747
  // app origin (mirrors the iframe's sandbox intent).
@@ -4033,7 +4097,7 @@
4033
4097
  $reportModal.dataset.nodeId = reportNodeId() || '';
4034
4098
  const cached = lastReportByApp.get(id);
4035
4099
  if (cached && cached.html) paintReport(cached.html);
4036
- else { $reportFrame.srcdoc = ''; $reportShare.hidden = true; }
4100
+ else { clearReportFrames(); $reportShare.hidden = true; }
4037
4101
  $reportOverlay.hidden = false;
4038
4102
  $reportOverlay.replaceChildren();
4039
4103
  $reportOverlay.append(mkEl('div', 'spinner'), mkEl('div', null, 'Starting live session…'));
@@ -4118,7 +4182,7 @@
4118
4182
  $reportShare.hidden = false; // the cached report is the primary content
4119
4183
  $reportSub.innerHTML = `<span class="live-pip" aria-hidden="true"></span>Live · waiting for the next change — change your selection in Tekla to refresh the report.`;
4120
4184
  } else {
4121
- $reportFrame.srcdoc = '';
4185
+ clearReportFrames();
4122
4186
  $reportShare.hidden = true;
4123
4187
  $reportOverlay.hidden = false;
4124
4188
  $reportOverlay.replaceChildren();
@@ -4146,7 +4210,7 @@
4146
4210
  ? `Run complete · last report above — click <strong>▶ Run workflow</strong> to run again.`
4147
4211
  : `Live session stopped · last report above — click <strong>▶ Run workflow</strong> to start again.`;
4148
4212
  } else {
4149
- $reportFrame.srcdoc = '';
4213
+ clearReportFrames();
4150
4214
  $reportShare.hidden = true;
4151
4215
  $reportOverlay.hidden = false;
4152
4216
  $reportOverlay.replaceChildren();
@@ -319,6 +319,13 @@
319
319
  Scripts stay BLOCKED (no allow-scripts), so even though the report is now
320
320
  same-origin it has no JS to touch the app page — static reports only. -->
321
321
  <iframe id="report-frame" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" title="Report"></iframe>
322
+ <!-- Interactive output (the viewer-3d agent) needs scripts, so it cannot use the
323
+ scriptless static frame above. Loaded via a `data:text/html` URL → an opaque
324
+ origin (cannot reach the app page, cookies, or same-origin APIs). Minimal
325
+ sandbox: `allow-scripts` ONLY — the 3D doc has no links/popups of its own
326
+ (↗ Open is a parent-side action), and NO allow-same-origin. Shown only for
327
+ interactive reports; the static frame is used otherwise. -->
328
+ <iframe id="report-frame-3d" sandbox="allow-scripts" title="3D view" hidden></iframe>
322
329
  <div class="report-overlay" id="report-overlay" hidden></div>
323
330
  </div>
324
331
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {