@floless/app 0.42.0 → 0.44.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 +499 -335
- package/dist/templates/college-phase-exporter.flo +86 -0
- package/dist/templates/dummy-report.flo +104 -0
- package/dist/templates/hello-world.flo +91 -0
- package/dist/templates/screenshot-to-saved-settings.flo +56 -0
- package/dist/templates/steel-from-drawings.flo +73 -0
- package/dist/templates/steel-takeoff.flo +151 -0
- package/dist/templates/steel-to-ifc.flo +294 -0
- package/dist/templates/steel-to-tekla.flo +293 -0
- package/dist/templates/supabase-drawing-register.flo +63 -0
- package/dist/templates/tekla-bom-by-phase.flo +211 -0
- package/dist/templates/tekla-connection-xray.flo +39 -0
- package/dist/templates/tekla-pdf-takeoff.flo +106 -0
- package/dist/templates/tekla-selection-now.flo +129 -0
- package/dist/templates/tekla-selection-report.flo +145 -0
- package/dist/templates/tekla-watch-logger.flo +57 -0
- package/dist/templates/tekla-watch-smoke.flo +20 -0
- package/dist/templates/trimble-list-projects.flo +41 -0
- package/dist/templates/viewer-3d-demo.flo +65 -0
- package/dist/web/app.css +17 -0
- package/dist/web/aware.js +79 -0
- package/dist/web/index.html +17 -0
- package/dist/web/steel-editor.html +40 -18
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
app: tekla-watch-logger
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Tekla Watch → file log
|
|
4
|
+
description: |
|
|
5
|
+
Event-triggered demo: a tekla.watch source streams every model change, and a
|
|
6
|
+
downstream exec node appends each event's details (mark, type, change, guid,
|
|
7
|
+
timestamp) to ~/Documents/floless-watch-log.txt — one line per event. Proves the
|
|
8
|
+
"On trigger" chain end-to-end and leaves a durable, app-closed-verifiable record.
|
|
9
|
+
self_test:true drives 5 synthetic events with no live Tekla; against a real model
|
|
10
|
+
it logs forever while the routine is enabled.
|
|
11
|
+
exposes-as-agent: false
|
|
12
|
+
layout: linear
|
|
13
|
+
requires:
|
|
14
|
+
- tekla@0.1.x
|
|
15
|
+
nodes:
|
|
16
|
+
- id: watch # SOURCE — streaming lifecycle:start; one fire per change
|
|
17
|
+
agent: tekla
|
|
18
|
+
command: watch
|
|
19
|
+
config:
|
|
20
|
+
filter: all
|
|
21
|
+
self_test: true
|
|
22
|
+
include_deleted: true
|
|
23
|
+
- id: log # SINK — append this event's details to the file
|
|
24
|
+
agent: tekla
|
|
25
|
+
command: exec
|
|
26
|
+
mode: read # author intent; AWARE defaults exec→write (issue #165), cosmetic
|
|
27
|
+
config:
|
|
28
|
+
version: "2025.0"
|
|
29
|
+
# Per-event payload bound by the streaming runtime as {{ inputs.<field> }}
|
|
30
|
+
# (orchestrator.propagate_from → execute_and_chain inserts the fired event
|
|
31
|
+
# under `inputs`). Fields: mark, type, change, guid, host, delivered_at.
|
|
32
|
+
args:
|
|
33
|
+
mark: "{{ inputs.mark }}"
|
|
34
|
+
type: "{{ inputs.type }}"
|
|
35
|
+
change: "{{ inputs.change }}"
|
|
36
|
+
guid: "{{ inputs.guid }}"
|
|
37
|
+
at: "{{ inputs.delivered_at }}"
|
|
38
|
+
code: |
|
|
39
|
+
// Append one line per fired event to ~/Documents/floless-watch-log.txt.
|
|
40
|
+
// System.IO is available in exec (System.Net / System.Diagnostics.Process are not).
|
|
41
|
+
string Get(string k) => args != null && args.ContainsKey(k) && args[k] != null
|
|
42
|
+
? args[k].ToString() : "";
|
|
43
|
+
string mark = Get("mark");
|
|
44
|
+
string type = Get("type");
|
|
45
|
+
string change = Get("change");
|
|
46
|
+
string guid = Get("guid");
|
|
47
|
+
string at = Get("at");
|
|
48
|
+
|
|
49
|
+
string dir = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
|
|
50
|
+
string path = System.IO.Path.Combine(dir, "floless-watch-log.txt");
|
|
51
|
+
string stampedAt = string.IsNullOrEmpty(at) ? System.DateTime.UtcNow.ToString("o") : at;
|
|
52
|
+
string line = string.Format("{0}\t{1}\t{2}\t{3}\tguid={4}", stampedAt, change, type, mark, guid);
|
|
53
|
+
System.IO.File.AppendAllText(path, line + System.Environment.NewLine);
|
|
54
|
+
|
|
55
|
+
return new { ok = true, logged = line, path = path };
|
|
56
|
+
connections:
|
|
57
|
+
- { from: watch, to: log }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
app: tekla-watch-smoke
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Tekla Watch (smoke)
|
|
4
|
+
description: |
|
|
5
|
+
Minimal tekla.watch event-source app for capturing the streaming run-trace
|
|
6
|
+
shape (self_test:true drives synthetic events with no live Tekla). Used to
|
|
7
|
+
ground floless.app's "On trigger" fire-detection.
|
|
8
|
+
exposes-as-agent: false
|
|
9
|
+
layout: linear
|
|
10
|
+
requires:
|
|
11
|
+
- tekla@0.1.x
|
|
12
|
+
nodes:
|
|
13
|
+
- id: watch
|
|
14
|
+
agent: tekla
|
|
15
|
+
command: watch
|
|
16
|
+
config:
|
|
17
|
+
filter: all
|
|
18
|
+
self_test: true
|
|
19
|
+
include_deleted: true
|
|
20
|
+
connections: []
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
app: trimble-list-projects
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Trimble — list my projects
|
|
4
|
+
description: |
|
|
5
|
+
Lists the Trimble Connect projects you can access (read-only) and renders them
|
|
6
|
+
as an HTML report. Uses the connected `trimble-connect` credential — AWARE
|
|
7
|
+
attaches the stored OAuth token as a Bearer automatically, floless.app never sees
|
|
8
|
+
it — then html-report's generic auto-render turns the result into a table the
|
|
9
|
+
in-app Viewer shows. Foundation demo for Trimble workflow support.
|
|
10
|
+
exposes-as-agent: false
|
|
11
|
+
requires:
|
|
12
|
+
- trimble-connect@0.2.x
|
|
13
|
+
- html-report@0.2.x
|
|
14
|
+
requires-permissions:
|
|
15
|
+
network:
|
|
16
|
+
- https://app.connect.trimble.com
|
|
17
|
+
layout: linear
|
|
18
|
+
# Explicit edge so `report` waits for `projects`. As of AWARE 0.59.0 (#208) a ref
|
|
19
|
+
# like `{{ projects.body }}` auto-derives this edge, so it's belt-and-suspenders now —
|
|
20
|
+
# kept for clarity.
|
|
21
|
+
connections:
|
|
22
|
+
- from: projects
|
|
23
|
+
to: report
|
|
24
|
+
nodes:
|
|
25
|
+
- id: projects
|
|
26
|
+
agent: trimble-connect
|
|
27
|
+
command: list-projects
|
|
28
|
+
config:
|
|
29
|
+
top: 100
|
|
30
|
+
- id: report
|
|
31
|
+
agent: html-report
|
|
32
|
+
command: render
|
|
33
|
+
config:
|
|
34
|
+
data: '{{ projects.body }}'
|
|
35
|
+
title: My Trimble Connect projects
|
|
36
|
+
# The API returns ~20 columns (incl. raw thumbnail URLs + createdBy JSON blobs);
|
|
37
|
+
# keep a clean, useful set and lead with the most recently modified.
|
|
38
|
+
# (html-report columns + sort, AWARE 0.61.0 / #210.)
|
|
39
|
+
columns: [name, location, modifiedOn, filesCount, foldersCount, access]
|
|
40
|
+
sort: modifiedOn
|
|
41
|
+
sort-desc: true
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
app: viewer-3d-demo
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: 3D Viewer Demo
|
|
4
|
+
description: |
|
|
5
|
+
A self-contained demo of the viewer-3d agent — renders a small steel portal frame as an
|
|
6
|
+
interactive 3D model in the in-app viewer (orbit/zoom, click a member to inspect, a legend
|
|
7
|
+
and a takeoff table). No Tekla model required: a single viewer-3d `render` node with an
|
|
8
|
+
inline, domain-agnostic scene. Use it to preview the interactive 3D surface end to end.
|
|
9
|
+
exposes-as-agent: false
|
|
10
|
+
requires:
|
|
11
|
+
- viewer-3d@0.1.x
|
|
12
|
+
layout: linear
|
|
13
|
+
nodes:
|
|
14
|
+
# The viewer-3d render node IS the terminal: it returns the interactive HTML the viewer shows.
|
|
15
|
+
- id: view
|
|
16
|
+
agent: viewer-3d
|
|
17
|
+
command: render
|
|
18
|
+
config:
|
|
19
|
+
scene:
|
|
20
|
+
meta: { name: "Sample portal frame — 20 m × 24 m", units: mm, up: z }
|
|
21
|
+
groups:
|
|
22
|
+
- { key: column, label: Columns, color: "#60a5fa" }
|
|
23
|
+
- { key: rafter, label: Rafters, color: "#38bdf8" }
|
|
24
|
+
- { key: beam, label: Beams, color: "#94a3b8" }
|
|
25
|
+
- { key: brace, label: Bracing, color: "#818cf8" }
|
|
26
|
+
elements:
|
|
27
|
+
# columns (z 0 -> 6000)
|
|
28
|
+
- { id: COL-A1, group: column, kind: box, from: [0,0,0], to: [0,0,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
29
|
+
- { id: COL-C1, group: column, kind: box, from: [20000,0,0], to: [20000,0,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
30
|
+
- { id: COL-A2, group: column, kind: box, from: [0,6000,0], to: [0,6000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
31
|
+
- { id: COL-C2, group: column, kind: box, from: [20000,6000,0], to: [20000,6000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
32
|
+
- { id: COL-A3, group: column, kind: box, from: [0,12000,0], to: [0,12000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
33
|
+
- { id: COL-C3, group: column, kind: box, from: [20000,12000,0], to: [20000,12000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
|
|
34
|
+
# rafters (eave -> apex -> eave) per frame
|
|
35
|
+
- { id: RAF-L1, group: rafter, kind: box, from: [0,0,6000], to: [10000,0,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
36
|
+
- { id: RAF-R1, group: rafter, kind: box, from: [10000,0,7500], to: [20000,0,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
37
|
+
- { id: RAF-L2, group: rafter, kind: box, from: [0,6000,6000], to: [10000,6000,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
38
|
+
- { id: RAF-R2, group: rafter, kind: box, from: [10000,6000,7500], to: [20000,6000,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
39
|
+
- { id: RAF-L3, group: rafter, kind: box, from: [0,12000,6000], to: [10000,12000,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
40
|
+
- { id: RAF-R3, group: rafter, kind: box, from: [10000,12000,7500], to: [20000,12000,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
|
|
41
|
+
# eaves beams (along Y, z 6000) + ridge (x 10000, z 7500)
|
|
42
|
+
- { id: EAV-A1, group: beam, kind: box, from: [0,0,6000], to: [0,6000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
43
|
+
- { id: EAV-A2, group: beam, kind: box, from: [0,6000,6000], to: [0,12000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
44
|
+
- { id: EAV-C1, group: beam, kind: box, from: [20000,0,6000], to: [20000,6000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
45
|
+
- { id: EAV-C2, group: beam, kind: box, from: [20000,6000,6000], to: [20000,12000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
46
|
+
- { id: RDG-1, group: beam, kind: box, from: [10000,0,7500], to: [10000,6000,7500], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
47
|
+
- { id: RDG-2, group: beam, kind: box, from: [10000,6000,7500], to: [10000,12000,7500], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
|
|
48
|
+
# vertical X-bracing (end bay, wall A)
|
|
49
|
+
- { id: BR-A1, group: brace, kind: box, from: [0,0,0], to: [0,6000,6000], section: { w: 140, d: 140 }, meta: { profile: "CHS 139.7x5.0" } }
|
|
50
|
+
- { id: BR-A2, group: brace, kind: box, from: [0,0,6000], to: [0,6000,0], section: { w: 140, d: 140 }, meta: { profile: "CHS 139.7x5.0" } }
|
|
51
|
+
grids:
|
|
52
|
+
- { label: A, at: [0,-2000,0] }
|
|
53
|
+
- { label: B, at: [10000,-2000,0] }
|
|
54
|
+
- { label: C, at: [20000,-2000,0] }
|
|
55
|
+
- { label: "1", at: [-2000,0,0] }
|
|
56
|
+
- { label: "3", at: [-2000,12000,0] }
|
|
57
|
+
panels:
|
|
58
|
+
- title: Takeoff
|
|
59
|
+
note: "Counted from the scene — no Tekla licence."
|
|
60
|
+
columns: [Section, "No.", Length]
|
|
61
|
+
rows:
|
|
62
|
+
- ["UC 305x305x97", 6, "36.0 m"]
|
|
63
|
+
- ["UB 457x191x67", 6, "60.7 m"]
|
|
64
|
+
- ["UB 305x165x40", 6, "36.0 m"]
|
|
65
|
+
- ["CHS 139.7x5.0", 2, "17.0 m"]
|
package/dist/web/app.css
CHANGED
|
@@ -1599,6 +1599,23 @@
|
|
|
1599
1599
|
.app-update:hover { border-color: var(--accent); color: var(--accent); }
|
|
1600
1600
|
.app-update[disabled] { opacity: 0.6; cursor: default; }
|
|
1601
1601
|
|
|
1602
|
+
/* Workflow-update pill (a published workflow has a newer version) — mirrors the app-update pill. */
|
|
1603
|
+
.wf-update-pill { display: none; border: 1px solid var(--accent-dim); background: var(--accent-soft); color: var(--accent-bright); font-size: 10px; padding: 1px 7px; border-radius: 999px; cursor: pointer; margin-left: 8px; line-height: 1.5; }
|
|
1604
|
+
.wf-update-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 4px; }
|
|
1605
|
+
.wf-update-pill:hover { border-color: var(--accent); color: var(--accent); }
|
|
1606
|
+
.wf-update-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1607
|
+
/* Changelog list inside the update modal — themed scroll container (no native bar leaks). */
|
|
1608
|
+
.wf-update-changelog { max-height: 280px; overflow-y: auto; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
|
1609
|
+
.wf-update-changelog::-webkit-scrollbar { width: 6px; }
|
|
1610
|
+
.wf-update-changelog::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
|
|
1611
|
+
.wf-update-changelog::-webkit-scrollbar-track { background: transparent; }
|
|
1612
|
+
.wf-update-changelog .wf-ver-head { font-family: var(--mono); font-weight: 700; color: var(--text); font-size: 12px; }
|
|
1613
|
+
.wf-update-changelog .wf-ver-sum { color: var(--text-muted); font-size: 11px; margin: 2px 0 6px; }
|
|
1614
|
+
.wf-update-changelog ul { margin: 0; padding-left: 16px; }
|
|
1615
|
+
.wf-update-changelog li { color: var(--text); font-size: 12px; margin: 2px 0; }
|
|
1616
|
+
#wf-update-sub { color: var(--text-muted); }
|
|
1617
|
+
.wf-update-note { font-size: 11px; color: var(--text-muted); margin: 0 0 16px; line-height: 1.5; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
1618
|
+
|
|
1602
1619
|
/* Add/edit form */
|
|
1603
1620
|
.modal.routine-edit { width: 540px; max-width: 92vw; max-height: 90vh; overflow-y: auto; }
|
|
1604
1621
|
#rtn-workflow, .rtn-kind-select { min-width: 0; width: 100%; }
|
package/dist/web/aware.js
CHANGED
|
@@ -533,6 +533,8 @@
|
|
|
533
533
|
|
|
534
534
|
// ── load / select / compile / run ───────────────────────────────────────────
|
|
535
535
|
|
|
536
|
+
let _wfUpdates = []; // workflow updates available, keyed by app id — polled by refreshWorkflowUpdates() below
|
|
537
|
+
|
|
536
538
|
async function loadApp(id) {
|
|
537
539
|
const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
|
|
538
540
|
apps.set(id, app);
|
|
@@ -554,6 +556,7 @@
|
|
|
554
556
|
markSpecialNodes();
|
|
555
557
|
paintGate(app);
|
|
556
558
|
refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
|
|
559
|
+
renderWfUpdatePill(); // show the "↑ Update to vX" pill when THIS app has a newer published version
|
|
557
560
|
}
|
|
558
561
|
|
|
559
562
|
async function loadApps() {
|
|
@@ -2826,6 +2829,82 @@
|
|
|
2826
2829
|
setInterval(refreshUpdate, 60 * 60 * 1000); // hourly backstop; the focus re-check (below) is the fast path
|
|
2827
2830
|
}
|
|
2828
2831
|
|
|
2832
|
+
// ── Workflow update (module-shaped) ─────────────────────────────────────────────
|
|
2833
|
+
// A FloLess-published workflow (e.g. steel-takeoff) has a newer bundled template. Poll GET
|
|
2834
|
+
// /api/workflows/updates; when the OPEN app has one, show a pill → modal with the changelog →
|
|
2835
|
+
// "Back up & update" POSTs /api/workflows/:id/update (backup → swap structure → re-bake the user's
|
|
2836
|
+
// data from the contract store → recompile) → reload the app. No auto-update. `_wfUpdates` + the
|
|
2837
|
+
// renderWfUpdatePill() call in loadApp() live earlier; renderWfUpdatePill queries the DOM so it's
|
|
2838
|
+
// safe to call before this block initialises.
|
|
2839
|
+
const $wfUpdate = document.getElementById('wf-update');
|
|
2840
|
+
const $wfUpdateModal = document.getElementById('wf-update-modal');
|
|
2841
|
+
let _wfUpdateApplying = false;
|
|
2842
|
+
function wfUpdateFor(id) { return _wfUpdates.find((u) => u.id === id) || null; }
|
|
2843
|
+
function renderWfUpdatePill() {
|
|
2844
|
+
const el = document.getElementById('wf-update');
|
|
2845
|
+
if (!el) return;
|
|
2846
|
+
const u = currentId ? wfUpdateFor(currentId) : null;
|
|
2847
|
+
if (u) {
|
|
2848
|
+
el.textContent = '↑ Update to v' + u.available;
|
|
2849
|
+
el.dataset.tip = 'A newer version of this workflow is available — see what changed';
|
|
2850
|
+
el.hidden = false;
|
|
2851
|
+
} else {
|
|
2852
|
+
el.hidden = true;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
async function refreshWorkflowUpdates() {
|
|
2856
|
+
try {
|
|
2857
|
+
const r = await fetch('/api/workflows/updates');
|
|
2858
|
+
const d = await r.json();
|
|
2859
|
+
_wfUpdates = (r.ok && d.ok && Array.isArray(d.updates)) ? d.updates : [];
|
|
2860
|
+
} catch { _wfUpdates = []; }
|
|
2861
|
+
renderWfUpdatePill();
|
|
2862
|
+
}
|
|
2863
|
+
function openWfUpdateModal(u) {
|
|
2864
|
+
if (!$wfUpdateModal) return;
|
|
2865
|
+
const a = apps.get(u.id);
|
|
2866
|
+
const name = (a && a.displayName) || u.id;
|
|
2867
|
+
document.getElementById('wf-update-title').textContent = `Update “${name}” to v${u.available}`;
|
|
2868
|
+
document.getElementById('wf-update-sub').textContent = `You’re on v${u.installed}. What’s new:`;
|
|
2869
|
+
const changelog = Array.isArray(u.changelog) ? u.changelog : [];
|
|
2870
|
+
document.getElementById('wf-update-changelog').innerHTML = changelog.map((e) => {
|
|
2871
|
+
const changes = Array.isArray(e.changes) && e.changes.length
|
|
2872
|
+
? '<ul>' + e.changes.map((c) => `<li>${escapeHtml(String(c))}</li>`).join('') + '</ul>' : '';
|
|
2873
|
+
return `<div class="wf-ver"><div class="wf-ver-head">v${escapeHtml(String(e.version))}</div>`
|
|
2874
|
+
+ (e.summary ? `<div class="wf-ver-sum">${escapeHtml(String(e.summary))}</div>` : '') + changes + '</div>';
|
|
2875
|
+
}).join('') || '<div class="wf-ver-sum">No changelog provided.</div>';
|
|
2876
|
+
showModal($wfUpdateModal);
|
|
2877
|
+
}
|
|
2878
|
+
async function applyWfUpdate(u) {
|
|
2879
|
+
if (_wfUpdateApplying) return;
|
|
2880
|
+
_wfUpdateApplying = true;
|
|
2881
|
+
const $ok = document.getElementById('wf-update-ok');
|
|
2882
|
+
const prev = $ok ? $ok.textContent : '';
|
|
2883
|
+
if ($ok) { $ok.disabled = true; $ok.textContent = 'Updating…'; }
|
|
2884
|
+
try {
|
|
2885
|
+
const r = await fetch(`/api/workflows/${encodeURIComponent(u.id)}/update`, { method: 'POST', headers: { 'content-type': 'application/json' } });
|
|
2886
|
+
const d = await r.json().catch(() => ({}));
|
|
2887
|
+
if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
|
|
2888
|
+
hideModal($wfUpdateModal);
|
|
2889
|
+
showToast(`Updated to v${d.toVersion} — your data is preserved, a backup was saved.`, 'ok');
|
|
2890
|
+
await refreshWorkflowUpdates();
|
|
2891
|
+
if (currentId === u.id) await loadApp(currentId); // reload to show the new structure
|
|
2892
|
+
} catch (e) {
|
|
2893
|
+
showToast('Workflow update failed — ' + String((e && e.message) || e).slice(0, 100), 'warn');
|
|
2894
|
+
} finally {
|
|
2895
|
+
_wfUpdateApplying = false;
|
|
2896
|
+
if ($ok) { $ok.disabled = false; $ok.textContent = prev; }
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
if ($wfUpdate) {
|
|
2900
|
+
$wfUpdate.onclick = () => { const u = currentId && wfUpdateFor(currentId); if (u) openWfUpdateModal(u); };
|
|
2901
|
+
const $cancel = document.getElementById('wf-update-cancel'); if ($cancel) $cancel.onclick = () => hideModal($wfUpdateModal);
|
|
2902
|
+
const $ok = document.getElementById('wf-update-ok'); if ($ok) $ok.onclick = () => { const u = currentId && wfUpdateFor(currentId); if (u) applyWfUpdate(u); };
|
|
2903
|
+
if ($wfUpdateModal) onBackdropDismiss($wfUpdateModal, () => hideModal($wfUpdateModal));
|
|
2904
|
+
refreshWorkflowUpdates();
|
|
2905
|
+
setInterval(refreshWorkflowUpdates, 60 * 60 * 1000);
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2829
2908
|
// Dev-override helper — the ONLY forced-visible path. Reveals a pill with a fixed
|
|
2830
2909
|
// version when localStorage['floless.dev.forceUpdate'] matches the component, so an
|
|
2831
2910
|
// E2E can exercise the popover without a real pending update. Returns true if forced.
|
package/dist/web/index.html
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
</div>
|
|
55
55
|
</div>
|
|
56
56
|
<select id="prompt-select" hidden></select>
|
|
57
|
+
<button id="wf-update" class="wf-update-pill" type="button" hidden aria-haspopup="dialog"></button>
|
|
57
58
|
<!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
|
|
58
59
|
(Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
|
|
59
60
|
<span class="ctl-sep" aria-hidden="true"></span>
|
|
@@ -241,6 +242,22 @@
|
|
|
241
242
|
</div>
|
|
242
243
|
</div>
|
|
243
244
|
|
|
245
|
+
<!-- Workflow update: a FloLess-published workflow has a newer version. The changelog (entries
|
|
246
|
+
newer than installed) renders into #wf-update-changelog; "Back up & update" swaps the
|
|
247
|
+
structure but keeps the user's data (re-baked from the contract store). -->
|
|
248
|
+
<div class="modal-backdrop" id="wf-update-modal">
|
|
249
|
+
<div class="modal">
|
|
250
|
+
<div class="modal-title" id="wf-update-title">Workflow update</div>
|
|
251
|
+
<div class="modal-sub" id="wf-update-sub"></div>
|
|
252
|
+
<div class="wf-update-changelog" id="wf-update-changelog"></div>
|
|
253
|
+
<div class="wf-update-note">Your work is safe — this updates the workflow's steps and keeps your data. A backup of the current version is saved first.</div>
|
|
254
|
+
<div class="modal-actions">
|
|
255
|
+
<button id="wf-update-cancel">Not now</button>
|
|
256
|
+
<button id="wf-update-ok" class="primary">↑ Back up & update</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
244
261
|
<div class="menu" id="menu" role="menu">
|
|
245
262
|
<button class="menu-item" data-action="open" role="menuitem">
|
|
246
263
|
<span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg></span>
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
#lbView.drag{cursor:grabbing}
|
|
144
144
|
#lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
|
|
145
145
|
line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
|
|
146
|
-
|
|
146
|
+
.snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
147
147
|
line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
|
|
148
148
|
line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
149
149
|
circle.dimend{fill:#22d3ee;pointer-events:none}
|
|
@@ -960,24 +960,34 @@ function rectHit(p0,p1,r){
|
|
|
960
960
|
const c=[[r[0],r[1]],[r[2],r[1]],[r[2],r[3]],[r[0],r[3]]];
|
|
961
961
|
for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
|
|
962
962
|
return false;}
|
|
963
|
-
// --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections
|
|
964
|
-
const SNAP_PX=9; let snapPts=[], snapSegs=[];
|
|
963
|
+
// --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
|
|
964
|
+
const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], lastSnapKind='end'; // lastSnapKind drives the marker glyph: 'mid' → triangle, else circle
|
|
965
|
+
const _mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
|
965
966
|
function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
|
|
966
|
-
snapPts=[];snapSegs=[];
|
|
967
|
-
for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);}
|
|
968
|
-
for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);}
|
|
967
|
+
snapPts=[];snapSegs=[];snapMids=[];
|
|
968
|
+
for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);snapMids.push(_mid(m.wp[0],m.wp[1]));}
|
|
969
|
+
for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);snapMids.push(_mid(s.a,s.b));}
|
|
970
|
+
if(dimsVisible&&Array.isArray(P.dims))for(const d of P.dims){if(ex.has(d.id))continue; // snap to a dimension's VISIBLE (offset) line — its ends, midpoint, and onto-line (dimGeo already gives g.mid)
|
|
971
|
+
const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}}
|
|
969
972
|
// nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
|
|
970
973
|
function nearestOnLine(x,y,tol){let bp=null,bd=tol;
|
|
971
974
|
for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
|
|
972
975
|
return bp?{x:bp[0],y:bp[1],d:bd}:null;}
|
|
973
|
-
function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
|
|
976
|
+
function snap(x,y){const tol=SNAP_PX/zoom;lastSnapKind='end';let bp=null,bd=tol;
|
|
974
977
|
for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
|
|
975
|
-
if(bp)return {x:bp[0],y:bp[1],hit:true};
|
|
976
|
-
|
|
977
|
-
|
|
978
|
+
if(bp)return {x:bp[0],y:bp[1],hit:true,kind:'end'}; // 1) endpoint / grid intersection (highest priority)
|
|
979
|
+
let mp=null,md=tol;for(const q of snapMids){const d=Math.hypot(q[0]-x,q[1]-y);if(d<md){md=d;mp=q;}}
|
|
980
|
+
if(mp){lastSnapKind='mid';return {x:mp[0],y:mp[1],hit:true,kind:'mid'};} // 2) midpoint of a member/segment/dimension line (△)
|
|
981
|
+
const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true,kind:'line'}; // 3) else snap ONTO a line (not only its endpoints)
|
|
982
|
+
let sx=x,sy=y,bx=tol,by=tol; // 4) else axis-align to a nearby point
|
|
978
983
|
for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
|
|
979
|
-
return {x:sx,y:sy,hit:false};}
|
|
980
|
-
|
|
984
|
+
return {x:sx,y:sy,hit:false,kind:null};}
|
|
985
|
+
// one persistent marker element; kind 'mid' draws an upward triangle, everything else the original circle. Defaults to the last snap()'s kind.
|
|
986
|
+
function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
|
|
987
|
+
const ns='http://www.w3.org/2000/svg';let el;
|
|
988
|
+
if(kind==='mid'){const r=8/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x} ${y-r} L ${x+0.866*r} ${y+0.5*r} L ${x-0.866*r} ${y+0.5*r} Z`);} // apex-up equilateral, centroid ON the snap point
|
|
989
|
+
else{el=document.createElementNS(ns,'circle');el.setAttribute('cx',x);el.setAttribute('cy',y);el.setAttribute('r',6/zoom);}
|
|
990
|
+
el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);}
|
|
981
991
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
|
|
982
992
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
983
993
|
// buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
|
|
@@ -1112,7 +1122,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
1112
1122
|
if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
|
|
1113
1123
|
if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
|
|
1114
1124
|
if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
|
|
1115
|
-
if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(
|
|
1125
|
+
if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(id);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps; exclude this dim so the end can't snap to its own line/midpoint)
|
|
1116
1126
|
if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
|
|
1117
1127
|
if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
|
|
1118
1128
|
selDimIds=new Set([did]);const d=P.dims.find(x=>x.id===did); // plain click → exactly this dim (collapses any multi-selection, so Delete removes only it)
|
|
@@ -1156,7 +1166,7 @@ svg.addEventListener('pointermove',e=>{
|
|
|
1156
1166
|
if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
|
|
1157
1167
|
if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
|
|
1158
1168
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
|
|
1159
|
-
if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1]);}else snapClear();}return;}
|
|
1169
|
+
if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1],'end');}else snapClear();}return;} // the cut lands ON the member → always the neutral circle, never a stale midpoint △ (e.g. Alt held = no snap() this frame)
|
|
1160
1170
|
if(!drag)return;const p=toSvg(e);
|
|
1161
1171
|
if(drag.type==='dimend'){const d=P.dims.find(x=>x.id===drag.id);if(d){let x=p.x,y=p.y;if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();if(drag.end===0)d.a=[x,y];else d.b=[x,y];render();}return;} // dragging a dim anchor → snap + re-measure live
|
|
1162
1172
|
if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y],d.rot);render();}return;}
|
|
@@ -1168,11 +1178,12 @@ svg.addEventListener('pointermove',e=>{
|
|
|
1168
1178
|
drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
|
|
1169
1179
|
if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
|
|
1170
1180
|
if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
|
|
1171
|
-
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
|
|
1181
|
+
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, midpoint △, OR line)
|
|
1172
1182
|
for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
|
|
1173
|
-
for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1183
|
+
for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'end'};}}
|
|
1184
|
+
for(const q of snapMids){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'mid'};}}
|
|
1185
|
+
const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny,kind:'line'};}} // also snap onto other lines
|
|
1186
|
+
if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1],best.kind);}else snapClear();}
|
|
1176
1187
|
else snapClear();
|
|
1177
1188
|
for(const it of drag.items){const m=byId(it.id);if(!m)continue;
|
|
1178
1189
|
m.wp[0]=[it.o0[0]+dx,it.o0[1]+dy];m.wp[1]=[it.o1[0]+dx,it.o1[1]+dy];updateLine(m);}
|
|
@@ -1704,6 +1715,17 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
|
|
|
1704
1715
|
{const tz={frame:{o:[0,0],u:[0,0]}};sanitizeFrame(tz);ok(tz.frame===null,'sanitizeFrame drops zero-length u');}
|
|
1705
1716
|
{const tb={frame:{o:[0,0],u:[1]}};sanitizeFrame(tb);ok(tb.frame===null,'sanitizeFrame drops malformed u');}
|
|
1706
1717
|
P.frame=sf; // restore
|
|
1718
|
+
// 6) snap — midpoint tier (△) + dimension lines as snap targets + self-snap exclusion
|
|
1719
|
+
{const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom;
|
|
1720
|
+
zoom=1;P.members=[{id:'tm',wp:[[0,0],[100,0]]}];P.segments=[];P.dims=[];dimsVisible=true;
|
|
1721
|
+
buildSnap(null);
|
|
1722
|
+
const e1=snap(1,1);ok(e1.hit&&e1.kind==='end'&&pnear([e1.x,e1.y],[0,0]),'snap → endpoint wins near a corner');
|
|
1723
|
+
const m1=snap(50,3);ok(m1.hit&&m1.kind==='mid'&&pnear([m1.x,m1.y],[50,0]),'snap → member midpoint (△)');
|
|
1724
|
+
P.dims=[{id:'td',a:[0,0],b:[100,0],axis:'free',off:20,rot:0}]; // visible dim line runs (0,20)→(100,20), mid (50,20)
|
|
1725
|
+
buildSnap(null);
|
|
1726
|
+
const d1=snap(50,18);ok(d1.hit&&d1.kind==='mid'&&pnear([d1.x,d1.y],[50,20]),'snap → dimension-line midpoint (△)');
|
|
1727
|
+
buildSnap('td');const d2=snap(50,18);ok(!d2.hit,'snap → excluded dim cannot self-snap');
|
|
1728
|
+
P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
|
|
1707
1729
|
const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
|
|
1708
1730
|
console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
|
|
1709
1731
|
})();}
|