@floless/app 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +404 -154
- package/dist/web/app.css +22 -1
- package/dist/web/aware.js +225 -26
- package/dist/web/index.html +7 -0
- package/package.json +1 -1
package/dist/web/app.css
CHANGED
|
@@ -1096,6 +1096,11 @@
|
|
|
1096
1096
|
/* A disabled action reads as inert — never a primary that looks clickable but isn't. */
|
|
1097
1097
|
.modal-actions button:disabled { opacity: 0.5; cursor: default; }
|
|
1098
1098
|
.modal-actions button.primary:disabled:hover { background: var(--accent); box-shadow: none; }
|
|
1099
|
+
/* Destructive confirm (#85): the commit button is neutral at rest and turns
|
|
1100
|
+
error-red only on hover, so an accidental Enter (focus sits on the accent Cancel)
|
|
1101
|
+
never deletes — matching the app's other destructive affordances. */
|
|
1102
|
+
.modal-actions button.danger { border-color: var(--border-strong); color: var(--text); font-weight: 600; }
|
|
1103
|
+
.modal-actions button.danger:hover { color: var(--err); border-color: var(--err); background: rgba(248, 113, 113, 0.1); box-shadow: none; }
|
|
1099
1104
|
.modal.library {
|
|
1100
1105
|
width: 720px;
|
|
1101
1106
|
max-height: 82vh;
|
|
@@ -1585,6 +1590,20 @@
|
|
|
1585
1590
|
.wf-option.selected .wf-option-name { color: var(--accent); }
|
|
1586
1591
|
.wf-option-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1587
1592
|
.wf-option-meta { font-family: var(--mono); font-size: 10px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
|
1593
|
+
/* Per-row lifecycle actions (#85) — hover/keyboard-revealed, mirroring the #68
|
|
1594
|
+
template chip's ✎/× treatment one level up (act on an installed workflow). */
|
|
1595
|
+
.wf-option-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
|
|
1596
|
+
.wf-act {
|
|
1597
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1598
|
+
width: 20px; height: 20px; padding: 0; border: 1px solid transparent; border-radius: 3px;
|
|
1599
|
+
background: transparent; color: var(--text-dim); font: inherit; font-size: 12px; line-height: 1;
|
|
1600
|
+
cursor: pointer; opacity: 0; transition: opacity .12s ease, color .12s, background .12s, border-color .12s;
|
|
1601
|
+
}
|
|
1602
|
+
.wf-option:hover .wf-act, .wf-option.highlighted .wf-act { opacity: 0.45; }
|
|
1603
|
+
.wf-act:hover { opacity: 1; }
|
|
1604
|
+
.wf-act.act-edit:hover { color: var(--accent); background: var(--accent-soft); }
|
|
1605
|
+
.wf-act.act-dup:hover { color: var(--text); border-color: var(--border-strong); }
|
|
1606
|
+
.wf-act.act-del:hover { color: var(--err); background: rgba(248, 113, 113, 0.1); border-color: var(--err); }
|
|
1588
1607
|
.wf-empty-search { color: var(--text-dim); font-style: italic; font-size: 12px; padding: 16px 10px; text-align: center; }
|
|
1589
1608
|
.wf-empty-search[hidden] { display: none; }
|
|
1590
1609
|
|
|
@@ -2190,7 +2209,9 @@ body {
|
|
|
2190
2209
|
.report-actions button.report-stop:hover { background: color-mix(in srgb, var(--warn) 16%, transparent); border-color: var(--warn); color: var(--text); }
|
|
2191
2210
|
#report-close { font-size: 18px; line-height: 1; padding: 4px 10px; }
|
|
2192
2211
|
.report-stage { position: relative; flex: 1; background: #0f172a; }
|
|
2193
|
-
|
|
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; }
|
|
2194
2215
|
|
|
2195
2216
|
/* Live "On trigger" report: a pulsing pip in the viewer subtitle + a brief edge
|
|
2196
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');
|
|
@@ -578,8 +579,17 @@
|
|
|
578
579
|
html += `<div class="wf-group" role="group" aria-label="${escapeAttr(providerLabel(p))}"><div class="wf-group-header">${escapeHtml(providerLabel(p))}</div>`;
|
|
579
580
|
for (const a of rows) {
|
|
580
581
|
const meta = `${escapeHtml(String(a.nodes))} node${a.nodes === 1 ? '' : 's'} · ${escapeHtml(String(a.layout))}`;
|
|
581
|
-
|
|
582
|
-
|
|
582
|
+
// A div (not a button) carries role="option" so the action icons can be real
|
|
583
|
+
// <button>s without nesting interactive elements (#85). Selection stays driven by
|
|
584
|
+
// the delegated $wfList click + comboMove highlight (aria-activedescendant).
|
|
585
|
+
const aid = escapeAttr(a.id);
|
|
586
|
+
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">`
|
|
587
|
+
+ `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
|
|
588
|
+
+ `<span class="wf-option-actions">`
|
|
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>`
|
|
590
|
+
+ `<button type="button" class="wf-act act-dup" data-wf-act="duplicate" data-id="${aid}" tabindex="-1" data-tip="Duplicate" aria-label="Duplicate ${aid}">⧉</button>`
|
|
591
|
+
+ `<button type="button" class="wf-act act-del" data-wf-act="delete" data-id="${aid}" tabindex="-1" data-tip="Delete" aria-label="Delete ${aid}">×</button>`
|
|
592
|
+
+ `</span></div>`;
|
|
583
593
|
}
|
|
584
594
|
html += '</div>';
|
|
585
595
|
}
|
|
@@ -695,6 +705,15 @@
|
|
|
695
705
|
}
|
|
696
706
|
if ($wfList) {
|
|
697
707
|
$wfList.addEventListener('click', (e) => {
|
|
708
|
+
// A row action (#85) takes precedence over selecting the workflow.
|
|
709
|
+
const act = e.target.closest('[data-wf-act]');
|
|
710
|
+
if (act) {
|
|
711
|
+
const id = act.dataset.id;
|
|
712
|
+
if (act.dataset.wfAct === 'rename') openRenameWorkflow(id);
|
|
713
|
+
else if (act.dataset.wfAct === 'duplicate') openDuplicateWorkflow(id);
|
|
714
|
+
else if (act.dataset.wfAct === 'delete') confirmDeleteWorkflow(id);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
698
717
|
const o = e.target.closest('.wf-option');
|
|
699
718
|
if (o) selectComboById(o.dataset.id);
|
|
700
719
|
});
|
|
@@ -703,6 +722,112 @@
|
|
|
703
722
|
if ($wfPopover && !$wfPopover.hidden && $wfCombo && !$wfCombo.contains(e.target)) closeCombo(false);
|
|
704
723
|
});
|
|
705
724
|
|
|
725
|
+
// ── Workflow lifecycle actions: rename / duplicate / delete (#85) ─────────────
|
|
726
|
+
// The server owns the fs work + `aware app compile`/`uninstall` + the app-id cascade
|
|
727
|
+
// (visual inputs, bound routines). Here we drive the modal, migrate the CLIENT-side
|
|
728
|
+
// saved input VALUES (localStorage, keyed by app id), set LS_LAST_APP so the
|
|
729
|
+
// apps-changed SSE → loadApps() reopens the right workflow, and toast the outcome.
|
|
730
|
+
function migrateInputsLS(fromId, toId) {
|
|
731
|
+
try {
|
|
732
|
+
const v = localStorage.getItem(lsInputsKey(fromId));
|
|
733
|
+
if (v != null) localStorage.setItem(lsInputsKey(toId), v);
|
|
734
|
+
} catch { /* private mode / quota */ }
|
|
735
|
+
}
|
|
736
|
+
function dropClientAppState(id) {
|
|
737
|
+
try { localStorage.removeItem(lsInputsKey(id)); } catch { /* ignore */ }
|
|
738
|
+
appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function openRenameWorkflow(oldId) {
|
|
742
|
+
closeCombo(false);
|
|
743
|
+
const res = await formModal({
|
|
744
|
+
title: 'Rename workflow',
|
|
745
|
+
sub: 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.',
|
|
746
|
+
fields: [{ name: 'name', label: 'New name', type: 'text', value: oldId, placeholder: 'e.g. tekla-bom-by-phase' }],
|
|
747
|
+
okLabel: 'Rename',
|
|
748
|
+
});
|
|
749
|
+
if (!res) return;
|
|
750
|
+
const newId = String(res.name || '').trim();
|
|
751
|
+
if (!newId || newId === oldId) return;
|
|
752
|
+
showToast('Renaming…', 'ok');
|
|
753
|
+
try {
|
|
754
|
+
const r = await api(`/api/app/${encodeURIComponent(oldId)}/rename`, { method: 'PATCH', body: JSON.stringify({ name: newId }) });
|
|
755
|
+
// Carry saved input VALUES + any unsaved in-memory edits/dirty baseline to the new id
|
|
756
|
+
// so a rename never silently drops the user's inputs (#85 review). loadApp() only seeds
|
|
757
|
+
// inputs when the id is absent, so pre-setting them here preserves unsaved edits.
|
|
758
|
+
migrateInputsLS(oldId, r.id);
|
|
759
|
+
if (appInputValues.has(oldId)) appInputValues.set(r.id, appInputValues.get(oldId));
|
|
760
|
+
if (dirtyBaseline.has(oldId)) dirtyBaseline.set(r.id, dirtyBaseline.get(oldId));
|
|
761
|
+
const wasOpen = currentId === oldId;
|
|
762
|
+
dropClientAppState(oldId);
|
|
763
|
+
if (wasOpen) { try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } }
|
|
764
|
+
await loadApps();
|
|
765
|
+
showToast(r.compiled ? `Renamed to “${r.id}”` : `Renamed to “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
|
|
766
|
+
} catch (e) {
|
|
767
|
+
console.error(e); // an unexpected client throw (not a clean server message) stays debuggable
|
|
768
|
+
showToast(e.message || 'Rename failed', 'warn');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function openDuplicateWorkflow(srcId) {
|
|
773
|
+
closeCombo(false);
|
|
774
|
+
const res = await formModal({
|
|
775
|
+
title: 'Duplicate workflow',
|
|
776
|
+
sub: 'Makes an independent copy you can edit and run separately — compiled and ready.',
|
|
777
|
+
fields: [{ name: 'name', label: 'New name', type: 'text', value: `${srcId}-copy`, placeholder: 'e.g. tekla-bom-by-phase-copy' }],
|
|
778
|
+
okLabel: 'Duplicate',
|
|
779
|
+
});
|
|
780
|
+
if (!res) return;
|
|
781
|
+
const newId = String(res.name || '').trim();
|
|
782
|
+
if (!newId) return;
|
|
783
|
+
showToast('Duplicating…', 'ok');
|
|
784
|
+
try {
|
|
785
|
+
const r = await api(`/api/app/${encodeURIComponent(srcId)}/duplicate`, { method: 'POST', body: JSON.stringify({ name: newId }) });
|
|
786
|
+
migrateInputsLS(srcId, r.id); // the copy starts with the source's saved inputs
|
|
787
|
+
try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } // open the copy
|
|
788
|
+
await loadApps();
|
|
789
|
+
showToast(r.compiled ? `Duplicated as “${r.id}”` : `Duplicated as “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
|
|
790
|
+
} catch (e) {
|
|
791
|
+
console.error(e);
|
|
792
|
+
showToast(e.message || 'Duplicate failed', 'warn');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function confirmDeleteWorkflow(id) {
|
|
797
|
+
// Warn honestly if a routine (schedule OR trigger) is bound — the server cascade removes it.
|
|
798
|
+
let routineNote = '';
|
|
799
|
+
try {
|
|
800
|
+
const { routines } = await api('/api/routines');
|
|
801
|
+
const bound = (routines || []).filter((r) => r.workflow === id).length;
|
|
802
|
+
if (bound) routineNote = ` Its ${bound} bound routine${bound === 1 ? '' : 's'} will also be removed.`;
|
|
803
|
+
} catch {
|
|
804
|
+
// Couldn't read routines — never UNDER-warn before an irreversible delete: state the
|
|
805
|
+
// worst case so the user knows bound routines (if any) go too (the cascade still removes them).
|
|
806
|
+
routineNote = ' Any routines bound to it will also be removed.';
|
|
807
|
+
}
|
|
808
|
+
closeCombo(false);
|
|
809
|
+
const ok = await formModal({
|
|
810
|
+
title: 'Delete workflow?',
|
|
811
|
+
sub: `Permanently delete “${id}” — its source, lock and saved inputs.${routineNote} This can’t be undone.`,
|
|
812
|
+
fields: [],
|
|
813
|
+
okLabel: 'Delete',
|
|
814
|
+
danger: true,
|
|
815
|
+
});
|
|
816
|
+
if (!ok) return;
|
|
817
|
+
showToast('Deleting…', 'ok');
|
|
818
|
+
try {
|
|
819
|
+
await api(`/api/app/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
820
|
+
const wasOpen = currentId === id;
|
|
821
|
+
dropClientAppState(id);
|
|
822
|
+
if (wasOpen) { currentId = null; try { localStorage.removeItem(LS_LAST_APP); } catch { /* ignore */ } }
|
|
823
|
+
await loadApps();
|
|
824
|
+
showToast(`Deleted “${id}”`, 'ok');
|
|
825
|
+
} catch (e) {
|
|
826
|
+
console.error(e);
|
|
827
|
+
showToast(e.message || 'Delete failed', 'err'); // a failed destructive delete reads as an error
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
706
831
|
// ── app inputs + HTML Viewer ────────────────────────────────────────────────
|
|
707
832
|
|
|
708
833
|
// The id of the report-viewer node — the terminal node a user double-clicks to
|
|
@@ -714,10 +839,10 @@
|
|
|
714
839
|
function reportNodeId() {
|
|
715
840
|
const app = currentId && apps.get(currentId);
|
|
716
841
|
if (!app || !app.nodes.length) return null;
|
|
717
|
-
// An html-report agent node renders
|
|
718
|
-
// an `html` field,
|
|
719
|
-
// report producer regardless of exec code; prefer it directly as the viewer node.
|
|
720
|
-
const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report');
|
|
842
|
+
// An html-report (static) or viewer-3d (interactive 3D) agent node renders HTML — its
|
|
843
|
+
// `render` command returns an `html` field, exactly what extractReportHtml keys on. So
|
|
844
|
+
// it's a report producer regardless of exec code; prefer it directly as the viewer node.
|
|
845
|
+
const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report' || n.agent === 'viewer-3d');
|
|
721
846
|
if (htmlReportNode) return htmlReportNode.id;
|
|
722
847
|
// Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
|
|
723
848
|
// code returns an `html` field (the `data.result.html` extractReportHtml keys
|
|
@@ -829,7 +954,11 @@
|
|
|
829
954
|
// clear anything we injected last render, so re-renders never stack
|
|
830
955
|
card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
|
|
831
956
|
if (isInput) addNodeInputs(card); // current values, above the button
|
|
832
|
-
if (isReport)
|
|
957
|
+
if (isReport) {
|
|
958
|
+
const rNode = app && app.nodes.find((n) => n.id === rid);
|
|
959
|
+
const viewLabel = rNode && rNode.agent === 'viewer-3d' ? 'View 3D ▸' : 'View report ▸';
|
|
960
|
+
addNodeAction(card, viewLabel, () => showReport(id));
|
|
961
|
+
}
|
|
833
962
|
if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
|
|
834
963
|
if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
|
|
835
964
|
});
|
|
@@ -1121,7 +1250,37 @@
|
|
|
1121
1250
|
|
|
1122
1251
|
// Share is visible exactly when the frame holds a report (the empty state HIDES it —
|
|
1123
1252
|
// never a disabled affordance with no path forward).
|
|
1124
|
-
|
|
1253
|
+
// The last HTML painted (static or 3D) — so ↗ Open works for both surfaces.
|
|
1254
|
+
let lastPaintedHtml = '';
|
|
1255
|
+
// Paint a report into the viewer. A STATIC report uses the scriptless srcdoc frame; an
|
|
1256
|
+
// INTERACTIVE 3D doc (viewer-3d) uses the script-enabled frame via a data: URL (opaque
|
|
1257
|
+
// origin, cannot touch the app page) and keeps a "Loading 3D view…" overlay until the doc
|
|
1258
|
+
// posts `viewer-ready`. Exactly one frame is shown at a time.
|
|
1259
|
+
function paintReport(html, interactive) {
|
|
1260
|
+
lastPaintedHtml = html || '';
|
|
1261
|
+
if (interactive) {
|
|
1262
|
+
$reportFrame.hidden = true; $reportFrame.srcdoc = '';
|
|
1263
|
+
$reportFrame3d.hidden = false;
|
|
1264
|
+
$reportFrame3d.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
|
|
1265
|
+
$reportShare.hidden = true; // a script doc can't be statically hosted — hide, don't disable
|
|
1266
|
+
$reportOverlay.hidden = false; $reportOverlay.replaceChildren();
|
|
1267
|
+
$reportOverlay.append(mkEl('div', 'spinner'), mkEl('div', null, 'Loading 3D view…'));
|
|
1268
|
+
} else {
|
|
1269
|
+
$reportFrame3d.hidden = true; $reportFrame3d.removeAttribute('src');
|
|
1270
|
+
$reportFrame.hidden = false; $reportFrame.srcdoc = html;
|
|
1271
|
+
$reportOverlay.hidden = true; $reportOverlay.replaceChildren();
|
|
1272
|
+
$reportShare.hidden = false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Reset the viewer to "no report shown": clear BOTH frames + the Open buffer, so a stale
|
|
1277
|
+
// interactive 3D doc can't linger (its WebGL loop running) behind an overlay, and ↗ Open
|
|
1278
|
+
// can't reopen a previous app's report. Used by every empty / transition state.
|
|
1279
|
+
function clearReportFrames() {
|
|
1280
|
+
$reportFrame.srcdoc = ''; $reportFrame.hidden = false;
|
|
1281
|
+
$reportFrame3d.hidden = true; $reportFrame3d.removeAttribute('src');
|
|
1282
|
+
lastPaintedHtml = '';
|
|
1283
|
+
}
|
|
1125
1284
|
|
|
1126
1285
|
// Double-click the HTML Viewer node → LOAD the last report (no run; running is
|
|
1127
1286
|
// the header "▶ Run workflow" button's job). Shows a prompt if nothing has run yet.
|
|
@@ -1134,10 +1293,16 @@
|
|
|
1134
1293
|
if (reportRunning) return; // a run is in flight — its overlay already shows progress
|
|
1135
1294
|
const cached = lastReportByApp.get(currentId);
|
|
1136
1295
|
if (cached && cached.html) {
|
|
1137
|
-
|
|
1138
|
-
|
|
1296
|
+
const sfx = cached.label ? ' · ' + cached.label : '';
|
|
1297
|
+
if (cached.interactive) {
|
|
1298
|
+
$reportTitle.textContent = `3D Viewer · ${app.displayName}`;
|
|
1299
|
+
$reportSub.textContent = `Last 3D view${sfx} — drag to orbit, scroll to zoom, click an element to inspect.`;
|
|
1300
|
+
} else {
|
|
1301
|
+
$reportSub.textContent = `Last report${sfx} — rendered from the run's output, never composed by the UI.`;
|
|
1302
|
+
}
|
|
1303
|
+
paintReport(cached.html, cached.interactive);
|
|
1139
1304
|
} else {
|
|
1140
|
-
|
|
1305
|
+
clearReportFrames();
|
|
1141
1306
|
$reportShare.hidden = true; // nothing to share — hide, don't disable
|
|
1142
1307
|
$reportOverlay.hidden = false;
|
|
1143
1308
|
$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>`;
|
|
@@ -1162,7 +1327,7 @@
|
|
|
1162
1327
|
const inputBadge = Object.entries(inputs).map(([k, v]) => `${k}=${v}`).join(' · ');
|
|
1163
1328
|
$reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
|
|
1164
1329
|
$reportSub.innerHTML = `Live run of <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — rendered from the node's output, never composed by the UI.`;
|
|
1165
|
-
|
|
1330
|
+
clearReportFrames(); // clear any previous report (static or 3D) before this run paints a fresh one
|
|
1166
1331
|
$reportShare.hidden = true; // the frame is blank — only a painted report is shareable
|
|
1167
1332
|
$reportOverlay.hidden = false;
|
|
1168
1333
|
$reportOverlay.innerHTML = opts.debug
|
|
@@ -1183,14 +1348,19 @@
|
|
|
1183
1348
|
// body, not real data) — prompt reconnect instead of rendering the noise.
|
|
1184
1349
|
if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
|
|
1185
1350
|
const html = res.report && res.report.html;
|
|
1351
|
+
const interactive = !!(res.report && res.report.interactive);
|
|
1186
1352
|
if (!html) {
|
|
1187
1353
|
$reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
|
|
1188
1354
|
return;
|
|
1189
1355
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1356
|
+
if (interactive) {
|
|
1357
|
+
$reportTitle.textContent = `3D Viewer · ${app.displayName}`;
|
|
1358
|
+
$reportSub.innerHTML = `Live 3D view from <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — drag to orbit, scroll to zoom, click an element to inspect.`;
|
|
1359
|
+
}
|
|
1360
|
+
paintReport(html, interactive);
|
|
1361
|
+
lastReportByApp.set(currentId, { nodeId, label: inputBadge, html, interactive });
|
|
1192
1362
|
$reportModal.dataset.html = '1';
|
|
1193
|
-
appendNarration(`Rendered the report from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
|
|
1363
|
+
appendNarration(`Rendered the ${interactive ? '3D view' : 'report'} from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
|
|
1194
1364
|
} catch (e) {
|
|
1195
1365
|
const msg = e && e.message ? e.message : String(e);
|
|
1196
1366
|
if (cancelRequested) {
|
|
@@ -1238,7 +1408,7 @@
|
|
|
1238
1408
|
const CRED_BODY_IDLE = 'Sign in again and then run the workflow — it takes about 30 seconds.';
|
|
1239
1409
|
|
|
1240
1410
|
function showCredentialReconnect(cred) {
|
|
1241
|
-
|
|
1411
|
+
clearReportFrames();
|
|
1242
1412
|
$reportShare.hidden = true;
|
|
1243
1413
|
$reportOverlay.hidden = false;
|
|
1244
1414
|
$reportOverlay.replaceChildren();
|
|
@@ -1349,7 +1519,7 @@
|
|
|
1349
1519
|
// `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
|
|
1350
1520
|
// { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
|
|
1351
1521
|
const $formModal = document.getElementById('form-modal');
|
|
1352
|
-
function formModal({ title, sub = '', fields, okLabel = 'Save' }) {
|
|
1522
|
+
function formModal({ title, sub = '', fields, okLabel = 'Save', danger = false }) {
|
|
1353
1523
|
return new Promise((resolve) => {
|
|
1354
1524
|
const $title = document.getElementById('form-modal-title');
|
|
1355
1525
|
const $sub = document.getElementById('form-modal-sub');
|
|
@@ -1387,9 +1557,16 @@
|
|
|
1387
1557
|
return `<div class="modal-field"><label for="${id}">${escapeHtml(f.label || f.name)}</label>${ctl}</div>`;
|
|
1388
1558
|
}).join('');
|
|
1389
1559
|
$ok.textContent = okLabel;
|
|
1560
|
+
// A destructive confirm flips the emphasis: the commit button is neutral→red
|
|
1561
|
+
// (.danger) and the SAFE Cancel becomes the accent primary + gets focus, so a
|
|
1562
|
+
// stray Enter never deletes (#85).
|
|
1563
|
+
$ok.classList.toggle('danger', !!danger);
|
|
1564
|
+
$ok.classList.toggle('primary', !danger);
|
|
1565
|
+
$cancel.classList.toggle('primary', !!danger);
|
|
1390
1566
|
showModal($formModal);
|
|
1391
1567
|
const first = $body.querySelector('input:not(.fm-file-input),textarea');
|
|
1392
1568
|
if (first) { first.focus(); if (first.select) first.select(); }
|
|
1569
|
+
else if (danger) $cancel.focus();
|
|
1393
1570
|
// ── images-field setup ──────────────────────────────────────────────────────
|
|
1394
1571
|
const imageStates = new Map();
|
|
1395
1572
|
$body.querySelectorAll('.fm-images').forEach((box) => {
|
|
@@ -1505,15 +1682,19 @@
|
|
|
1505
1682
|
};
|
|
1506
1683
|
const done = (result) => {
|
|
1507
1684
|
hideModal($formModal);
|
|
1508
|
-
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $
|
|
1685
|
+
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
|
|
1686
|
+
$ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
|
|
1509
1687
|
resolve(result);
|
|
1510
1688
|
};
|
|
1511
1689
|
$ok.onclick = () => done(collect());
|
|
1512
1690
|
$cancel.onclick = () => done(null);
|
|
1513
1691
|
onBackdropDismiss($formModal, () => done(null));
|
|
1514
|
-
$body
|
|
1515
|
-
|
|
1516
|
-
|
|
1692
|
+
// Keydown at the MODAL level (not $body) so Esc cancels regardless of focus — e.g. a
|
|
1693
|
+
// destructive confirm where focus sits on Cancel, not in the (empty) body. Enter
|
|
1694
|
+
// commits only for a normal form (never a danger confirm; never inside a textarea).
|
|
1695
|
+
$formModal.onkeydown = (e) => {
|
|
1696
|
+
if (e.key === 'Escape') { e.preventDefault(); done(null); }
|
|
1697
|
+
else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
|
|
1517
1698
|
};
|
|
1518
1699
|
});
|
|
1519
1700
|
}
|
|
@@ -1539,10 +1720,22 @@
|
|
|
1539
1720
|
// The Stop button is rebuilt into the overlay each run — delegate so one
|
|
1540
1721
|
// listener survives every innerHTML swap.
|
|
1541
1722
|
$reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
|
|
1723
|
+
// The interactive 3D doc (viewer-3d) posts a load handshake from its opaque-origin frame:
|
|
1724
|
+
// `viewer-ready` clears the loading overlay; `viewer-error` (CDN/script/timeout) shows a
|
|
1725
|
+
// retry. Guard: only when the 3D frame is the active surface and the message is from it.
|
|
1726
|
+
window.addEventListener('message', (e) => {
|
|
1727
|
+
if ($reportFrame3d.hidden || e.source !== $reportFrame3d.contentWindow) return;
|
|
1728
|
+
const t = e.data && e.data.type;
|
|
1729
|
+
if (t === 'viewer-ready') { $reportOverlay.hidden = true; $reportOverlay.replaceChildren(); }
|
|
1730
|
+
else if (t === 'viewer-error') {
|
|
1731
|
+
$reportOverlay.hidden = false; $reportOverlay.replaceChildren();
|
|
1732
|
+
$reportOverlay.append(mkEl('div', null, '3D view failed to load — check your connection, then click ▶ Run workflow to try again.'));
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1542
1735
|
// The header ■ Stop run — the always-reachable twin of the overlay Stop (#39).
|
|
1543
1736
|
if ($stopRunBtn) $stopRunBtn.onclick = () => stopRun();
|
|
1544
1737
|
$reportOpen.onclick = () => {
|
|
1545
|
-
const html = $reportFrame.srcdoc;
|
|
1738
|
+
const html = lastPaintedHtml || $reportFrame.srcdoc;
|
|
1546
1739
|
if (!html) return;
|
|
1547
1740
|
// Open via a blob: URL — an opaque origin, so the report can't script the
|
|
1548
1741
|
// app origin (mirrors the iframe's sandbox intent).
|
|
@@ -3118,7 +3311,13 @@
|
|
|
3118
3311
|
// The host AI (or anyone) edited this app's .flo/.lock in the terminal —
|
|
3119
3312
|
// refresh so the canvas + gate reflect reality.
|
|
3120
3313
|
clearTimeout(reloadTimer);
|
|
3121
|
-
reloadTimer = setTimeout(() =>
|
|
3314
|
+
reloadTimer = setTimeout(() => {
|
|
3315
|
+
// Skip if the open app was renamed/removed out from under us (#85) — the
|
|
3316
|
+
// apps-changed handler already refreshes the picker, and reloading a gone id
|
|
3317
|
+
// would 404. dropClientAppState() removes it from `apps` before this fires.
|
|
3318
|
+
if (!currentId || !apps.has(currentId)) return;
|
|
3319
|
+
loadApp(currentId).catch(reportErr);
|
|
3320
|
+
}, 250);
|
|
3122
3321
|
} else if (m.type === 'seat-taken') {
|
|
3123
3322
|
// This session's seat was claimed by another device (newest-login-wins).
|
|
3124
3323
|
showToast('Signed in on another device — sign in here to continue.', 'err');
|
|
@@ -3892,7 +4091,7 @@
|
|
|
3892
4091
|
$reportModal.dataset.nodeId = reportNodeId() || '';
|
|
3893
4092
|
const cached = lastReportByApp.get(id);
|
|
3894
4093
|
if (cached && cached.html) paintReport(cached.html);
|
|
3895
|
-
else {
|
|
4094
|
+
else { clearReportFrames(); $reportShare.hidden = true; }
|
|
3896
4095
|
$reportOverlay.hidden = false;
|
|
3897
4096
|
$reportOverlay.replaceChildren();
|
|
3898
4097
|
$reportOverlay.append(mkEl('div', 'spinner'), mkEl('div', null, 'Starting live session…'));
|
|
@@ -3977,7 +4176,7 @@
|
|
|
3977
4176
|
$reportShare.hidden = false; // the cached report is the primary content
|
|
3978
4177
|
$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.`;
|
|
3979
4178
|
} else {
|
|
3980
|
-
|
|
4179
|
+
clearReportFrames();
|
|
3981
4180
|
$reportShare.hidden = true;
|
|
3982
4181
|
$reportOverlay.hidden = false;
|
|
3983
4182
|
$reportOverlay.replaceChildren();
|
|
@@ -4005,7 +4204,7 @@
|
|
|
4005
4204
|
? `Run complete · last report above — click <strong>▶ Run workflow</strong> to run again.`
|
|
4006
4205
|
: `Live session stopped · last report above — click <strong>▶ Run workflow</strong> to start again.`;
|
|
4007
4206
|
} else {
|
|
4008
|
-
|
|
4207
|
+
clearReportFrames();
|
|
4009
4208
|
$reportShare.hidden = true;
|
|
4010
4209
|
$reportOverlay.hidden = false;
|
|
4011
4210
|
$reportOverlay.replaceChildren();
|
package/dist/web/index.html
CHANGED
|
@@ -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>
|