@floless/app 0.28.0 → 0.30.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.
@@ -0,0 +1,148 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://floless.app/schemas/steel.takeoff/v1",
4
+ "title": "steel.takeoff/v1",
5
+ "description": "Typed contract emitted by the steel-takeoff-us reader and rendered by the floless contract editor. The contract is the single source of truth; the editor is its view, the exporters (IFC/Tekla/viewer-3d) consume it, Approve bakes the .lock. A multi-plan container: each plan is one framing sheet. All coordinates are DISPLAY space; elevations are decimal inches. Confidential rasters ride inside as base64 and are never committed. Field reference: docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md §3.",
6
+ "type": "object",
7
+ "required": ["type", "plans"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "type": { "const": "steel.takeoff/v1", "description": "Renderer-registry discriminator." },
11
+ "active": { "type": "integer", "description": "Index into plans of the active sheet." },
12
+ "palette": { "type": "array", "items": { "type": "string" }, "description": "Default per-index profile colours." },
13
+ "weights": { "type": "object", "additionalProperties": { "type": ["number", "null"] }, "description": "profile -> plf (pounds per linear foot), from the AISC lookup. null for an unresolved profile (e.g. an unsized moment-frame mark) that has no weight yet." },
14
+ "profile_colors": { "type": "object", "additionalProperties": { "type": "string" }, "description": "profile -> #hex user overrides." },
15
+ "sheet_previews": { "type": "object", "additionalProperties": { "type": "string" }, "description": "sheet -> base64 JPEG full-sheet preview (title block excluded)." },
16
+ "custom_details": { "type": "object", "additionalProperties": { "type": "string" }, "description": "user-created detail name -> base64 JPEG." },
17
+ "moment_frames": { "type": "array", "description": "Moment-frame schedule from the frame-elevation sheets." },
18
+ "detail_bubbles": {
19
+ "type": "object",
20
+ "additionalProperties": {
21
+ "type": "object",
22
+ "additionalProperties": { "$ref": "#/$defs/point2" }
23
+ },
24
+ "description": "sheet -> { detail number -> normalized [fx,fy] bubble anchor }."
25
+ },
26
+ "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
27
+ },
28
+ "$defs": {
29
+ "point2": {
30
+ "type": "array",
31
+ "items": { "type": "number" },
32
+ "minItems": 2,
33
+ "maxItems": 2,
34
+ "description": "An [x,y] pair in display space (or [fx,fy] normalized for bubbles)."
35
+ },
36
+ "plan": {
37
+ "type": "object",
38
+ "required": ["sheet", "members"],
39
+ "additionalProperties": true,
40
+ "properties": {
41
+ "sheet": { "type": "string", "description": "Sheet number, e.g. S-202." },
42
+ "title": { "type": "string" },
43
+ "clip": {
44
+ "type": "array",
45
+ "items": { "type": "number" },
46
+ "minItems": 4,
47
+ "maxItems": 4,
48
+ "description": "[x0,y0,x1,y1] display-space crop of the framing area."
49
+ },
50
+ "pt_per_ft": { "type": "number", "description": "Display points per foot (the drawing scale)." },
51
+ "default_tos": { "type": "number", "description": "Default top-of-steel elevation in inches for blank ends on this plan." },
52
+ "raster_b64": { "type": "string", "description": "Base64 raster of the framing area. CONFIDENTIAL — never committed." },
53
+ "segments": { "type": "array", "items": { "$ref": "#/$defs/segment" } },
54
+ "labels": { "type": "array", "items": { "$ref": "#/$defs/label" } },
55
+ "details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
56
+ "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
57
+ "members": { "type": "array", "items": { "$ref": "#/$defs/member" } }
58
+ }
59
+ },
60
+ "segment": {
61
+ "type": "object",
62
+ "required": ["id", "a", "b"],
63
+ "additionalProperties": true,
64
+ "properties": {
65
+ "id": { "type": "string" },
66
+ "a": { "$ref": "#/$defs/point2" },
67
+ "b": { "$ref": "#/$defs/point2" },
68
+ "o": { "enum": ["H", "V", "D"], "description": "Orientation: horizontal / vertical / diagonal." }
69
+ }
70
+ },
71
+ "label": {
72
+ "type": "object",
73
+ "required": ["text", "disp"],
74
+ "additionalProperties": true,
75
+ "properties": {
76
+ "text": { "type": "string" },
77
+ "disp": { "$ref": "#/$defs/point2" }
78
+ }
79
+ },
80
+ "detail": {
81
+ "type": "object",
82
+ "required": ["text"],
83
+ "additionalProperties": true,
84
+ "properties": {
85
+ "text": { "type": "string", "description": "Callout, e.g. \"2-S-302\"." },
86
+ "disp": { "$ref": "#/$defs/point2" }
87
+ }
88
+ },
89
+ "tos_callout": {
90
+ "type": "object",
91
+ "required": ["elev_in"],
92
+ "additionalProperties": true,
93
+ "properties": {
94
+ "elev_in": { "type": "number", "description": "Top-of elevation in inches." },
95
+ "type": { "enum": ["TOS", "TOD", "TOC"], "description": "Top of steel / deck / concrete." },
96
+ "disp": { "$ref": "#/$defs/point2" }
97
+ }
98
+ },
99
+ "member": {
100
+ "type": "object",
101
+ "required": ["id", "profile", "wp"],
102
+ "additionalProperties": true,
103
+ "properties": {
104
+ "id": { "type": "string" },
105
+ "profile": { "type": "string", "description": "AISC designation (e.g. W16X26) or an unresolved mark (e.g. MF). Empty string allowed for an unprofiled member." },
106
+ "wp": {
107
+ "type": "array",
108
+ "items": { "$ref": "#/$defs/point2" },
109
+ "minItems": 2,
110
+ "maxItems": 2,
111
+ "description": "Work-point endpoints [[x,y],[x,y]] in display space."
112
+ },
113
+ "angle": { "enum": ["H", "V", "D"] },
114
+ "role": { "enum": ["beam", "column"], "description": "Defaults to beam when absent." },
115
+ "rfi": { "type": "boolean", "description": "True when the size is unresolved (excluded from the BOM)." },
116
+ "mf": { "type": "boolean", "description": "Moment-frame member (persistent flag)." },
117
+ "ends": {
118
+ "type": "array",
119
+ "items": { "$ref": "#/$defs/end" },
120
+ "minItems": 2,
121
+ "maxItems": 2,
122
+ "description": "Per-end metadata (beam variant); index 0 and 1 match wp's two endpoints."
123
+ },
124
+ "col": { "$ref": "#/$defs/col" }
125
+ }
126
+ },
127
+ "end": {
128
+ "type": "object",
129
+ "additionalProperties": true,
130
+ "properties": {
131
+ "tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches at this end." },
132
+ "note": { "type": "string", "description": "Connection note (e.g. moment, shear, braced)." },
133
+ "tosDef": { "type": "boolean", "description": "True = follows the plan default; false = read from a drawing callout or set by hand." },
134
+ "detail": { "type": "string", "description": "Connection detail reference, e.g. \"5-S504\"." }
135
+ }
136
+ },
137
+ "col": {
138
+ "type": "object",
139
+ "additionalProperties": true,
140
+ "properties": {
141
+ "bos": { "type": ["number", "null"], "description": "Bottom-of-steel elevation in inches." },
142
+ "tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches." },
143
+ "note": { "type": "string" },
144
+ "detail": { "type": "string" }
145
+ }
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: floless-app-tweak-contract
3
+ description: This skill should be used when there is a pending floless `tweak-contract` request — the user typed an instruction in the steel-takeoff contract editor and clicked Send (Ask AI). Also triggers on asks like "edit the steel takeoff contract via AI", "adjust the steel takeoff in the contract editor", "the user asked me to change the steel contract", "pick up my tweak-contract request", "update the member profile in the contract". It teaches the host AI to read the request, fetch the current steel.takeoff/v1 contract, apply the instruction (and any pasted screenshots), PUT the updated contract back, and resolve the request.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Handle a `tweak-contract` request (Slice 2 AI round-trip)
9
+
10
+ ## What this is
11
+
12
+ The floless.app steel-takeoff contract editor surfaces an **Ask AI** panel: the user types
13
+ an instruction ("change all W12X26 to W14X30", "add a new beam on sheet S-202", "mark this
14
+ member as moment-frame") and optionally pastes screenshots, then clicks **Send**. That records
15
+ a `tweak-contract` request in the reverse channel. You pick it up here, edit the contract
16
+ document, write it back, and the editor reloads it.
17
+
18
+ The contract is `~/.floless/contracts/<appId>.json`, a `steel.takeoff/v1` document. It is the
19
+ single source of truth — the editor is its view, the exporters (IFC/Tekla/viewer-3d) consume
20
+ it, and **Approve** bakes the lock. A PUT writeback is schema-validated on the server; a 400
21
+ means invalid — fix and retry.
22
+
23
+ > **Pasted a request?** If the user pastes a message with a `[floless-request type=tweak-contract id=…]`
24
+ > marker, that is a request **copied from the FloLess Dashboard**. Resolve it via
25
+ > `GET /api/requests` (match the id) — don't act on the raw marker — then follow the loop
26
+ > below and `DELETE` it when done.
27
+
28
+ ## The loop
29
+
30
+ 1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
31
+ `type: "tweak-contract"`. It carries:
32
+ - `id` — the request id (used to DELETE it when done)
33
+ - `appId` — which app's contract to edit
34
+ - `instruction` — the user's plain-English ask
35
+ - `snapshots` — absolute paths to screenshot files saved under
36
+ `~/.floless/requests/<id>/` (PNG/JPEG/WebP). These are the key input — the user
37
+ pasted them to show you exactly which region or member they mean.
38
+
39
+ The port is the same base URL the floless.app server is on (check `/api/health` or use
40
+ the port the skill context provides — typically 4317 for the installed app, 4318 for the
41
+ dev server).
42
+
43
+ 2. **Read the current contract.** `GET http://localhost:<port>/api/contract/<appId>` returns
44
+ the full `steel.takeoff/v1` JSON document. You can also read it directly from
45
+ `~/.floless/contracts/<appId>.json`.
46
+
47
+ The schema is at `schemas/steel.takeoff.v1.schema.json` (in the floless.app repo) and the
48
+ field reference is `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md` §3.
49
+ Key shapes you'll edit:
50
+ - Top-level: `type` (const `"steel.takeoff/v1"`), `active`, `palette`, `weights{profile→plf}`,
51
+ `profile_colors{profile→#hex}`, `plans[]`
52
+ - `plans[i]`: `sheet`, `title`, `clip`, `pt_per_ft`, `default_tos`, `segments[]`,
53
+ `labels[]`, `details[]`, `tos_callouts[]`, `members[]`
54
+ - `members[j]`: `id` (required), `profile` (AISC designation e.g. `W16X26` or mark e.g. `MF`),
55
+ `wp` ([[x,y],[x,y]] work-point endpoints in display space), `angle` (`H`|`V`|`D`),
56
+ `role` (`beam`|`column`), `rfi` (true = no resolved AISC size), `mf` (moment-frame flag),
57
+ `ends[2]` (per-end: `tos` in inches, `note`, `tosDef`, `detail`),
58
+ `col` (column variant: `bos`, `tos`, `note`, `detail`)
59
+
60
+ 3. **Understand the ask.** Read the `instruction` text AND open the `snapshots[]` image files
61
+ with your vision — the screenshots are the primary input. They show the user pointing at a
62
+ specific member, region, or annotation on the canvas. What the text alone says ("change this
63
+ beam") only resolves with the visual context ("this beam" = the one circled in red on the
64
+ screenshot).
65
+
66
+ 4. **Produce the edit.** Modify the contract JSON to satisfy the instruction:
67
+ - Profile changes: update `members[j].profile`; if the new profile resolves in the AISC
68
+ table update `weights` too (plf from the AISC designation). Set `rfi: false` when you
69
+ assign a real AISC size; `rfi: true` for marks (`MF`, `BF`) or unknowns.
70
+ - Add a member: append to `plans[i].members[]` with a valid `{id, profile, wp}` minimum.
71
+ Use a unique `id` (e.g. `m-<uuid-fragment>`). Infer geometry from context/screenshot.
72
+ - Delete a member: remove it from `members[]`.
73
+ - Elevation changes: update `ends[].tos` (decimal inches) or `col.tos`/`col.bos`.
74
+ `tosDef: false` means the value came from the drawing, not the plan default.
75
+ - Connection notes / detail refs: update `ends[].note` or `ends[].detail` (e.g. `"5-S504"`).
76
+ - Flag adjustments: `mf: true` for moment-frame members; `rfi: true` for unresolved sizes.
77
+ - **Preserve everything you're not changing** — v1 is a full-document writeback; partial
78
+ objects are not supported.
79
+
80
+ 5. **Write it back.** `PUT http://localhost:<port>/api/contract/<appId>` with the full updated
81
+ document (Content-Type: application/json). A **400** response means schema-invalid — read
82
+ the error, fix the structure against `schemas/steel.takeoff.v1.schema.json`, and retry.
83
+ On success the server broadcasts a `contract-changed` event; the editor picks it up and
84
+ prompts to reload.
85
+
86
+ 6. **Tell the user.** Summarize what you changed (e.g. "Updated W12X26 → W14X30 on 4 beams
87
+ in sheet S-202; updated `weights` entry to 30 plf"). Tell them to click **↻ Reload** in the
88
+ editor (or accept the reload banner) to see the change. Then resolve the request:
89
+
90
+ `DELETE http://localhost:<port>/api/requests/<id>`
91
+
92
+ so it doesn't reappear in the queue.
93
+
94
+ ## Guardrails
95
+
96
+ - **Schema is the guardrail.** A 400 on PUT is the server rejecting invalid JSON — don't
97
+ skip it. Read `schemas/steel.takeoff.v1.schema.json` and fix the issue before retrying.
98
+ - **Preserve unedited data.** Coordinates, rasters (`raster_b64`), detail bubbles, sheet
99
+ previews — if you're not changing them, copy them through unchanged. Dropping fields silently
100
+ corrupts the contract.
101
+ - **Coordinates are display space, elevations are decimal inches.** Don't confuse them. Member
102
+ geometry (`wp`, `segments`) is in display-space points (not feet, not native PDF coords).
103
+ `tos` / `bos` / `elev_in` fields are always decimal inches (`16.5` = 16½ inches).
104
+ - **Screenshots don't lie; instructions generalize.** If the screenshot shows a specific member
105
+ and the instruction says "this beam", resolve "this" from the visual, not a guess.
106
+ - **Never auto-approve.** Writing the contract back is the AI act; the user still clicks
107
+ **Approve** in the editor to bake the lock. You write; they gate.
108
+ - **Thin-UI contract holds.** The browser recorded intent + screenshots; the brain is you, in
109
+ the terminal. The editor never edits the contract directly (it's read-only from its side
110
+ until the user clicks Save after a manual edit — and that is a different path). This skill
111
+ is the AI path.
package/dist/web/app.css CHANGED
@@ -608,6 +608,20 @@
608
608
  border: 1px solid var(--accent-dim); border-radius: 999px;
609
609
  cursor: help;
610
610
  }
611
+ /* Frozen node (aware app freeze): its last output is pinned and Run skips this node.
612
+ A quiet, INERT read — badge + a one-step-cooler surface — NOT a disabled state (the
613
+ card stays fully interactive; it's just static). Mirrors the rt-model-badge recipe but
614
+ with neutral-cool tokens so it reads distinct from the accent-blue runtime-model badge.
615
+ (No left ::after ribbon: that pseudo-element is already the node mode stripe.) */
616
+ .agent-card.frozen { background: var(--surface-2); }
617
+ .agent-card .frozen-badge {
618
+ display: inline-flex; align-items: center; gap: 4px;
619
+ margin: 0 0 8px; padding: 2px 8px;
620
+ font: 600 10px/1.4 var(--ui); letter-spacing: 0.01em;
621
+ color: var(--text-muted); background: var(--surface-3);
622
+ border: 1px solid var(--border-strong); border-radius: 999px;
623
+ cursor: help;
624
+ }
611
625
  .agent-card .footer-row {
612
626
  display: flex;
613
627
  justify-content: space-between;
@@ -1855,6 +1869,19 @@
1855
1869
  background: var(--border);
1856
1870
  margin: 5px 4px;
1857
1871
  }
1872
+ /* Cursor-anchored node context menu (right-click a card). Reuses .menu/.menu-item; only
1873
+ the fixed 280px width is wrong for it — the pointer anchor (top/left) is set inline. */
1874
+ .menu.context-menu { width: auto; min-width: 168px; }
1875
+ /* Non-interactive node-name header line atop the context menu. */
1876
+ .menu-head {
1877
+ padding: 6px 9px 4px;
1878
+ font-size: 11px;
1879
+ color: var(--text-dim);
1880
+ max-width: 220px;
1881
+ overflow: hidden;
1882
+ text-overflow: ellipsis;
1883
+ white-space: nowrap;
1884
+ }
1858
1885
  .menu-theme {
1859
1886
  display: flex;
1860
1887
  align-items: center;
@@ -3010,6 +3037,37 @@ body {
3010
3037
  /* Inspect-tab panels reuse the same block styles inside the inspect body. */
3011
3038
  .ext-inspect { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 0; }
3012
3039
 
3040
+ /* ── Contract editor overlay ────────────────────────────────────────────────
3041
+ Full-window surface for an editable typed contract (steel takeoff, …).
3042
+ Same-origin iframe, so the editor page calls /api/contract directly.
3043
+ z-index 60 sits above the 3-col app (z 1–8) and below any modal backdrops (z 70+). */
3044
+ .contract-editor {
3045
+ position: fixed; inset: 0; z-index: 60;
3046
+ display: flex; flex-direction: column;
3047
+ background: var(--bg);
3048
+ }
3049
+ .contract-editor[hidden] { display: none; }
3050
+ .contract-editor-bar {
3051
+ display: flex; align-items: center; gap: 12px;
3052
+ padding: 8px 14px;
3053
+ background: var(--surface-2);
3054
+ border-bottom: 1px solid var(--border);
3055
+ flex: none;
3056
+ }
3057
+ .contract-editor-title {
3058
+ color: var(--text-muted);
3059
+ font-size: 13px;
3060
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3061
+ }
3062
+ /* The Approve button reuses the same .primary recipe as modal primary actions. */
3063
+ .contract-editor-bar .primary {
3064
+ background: var(--accent); color: #fff; border: 1px solid var(--accent);
3065
+ padding: 6px 14px; border-radius: 6px; font-size: 12.5px; font-weight: 600; cursor: pointer;
3066
+ }
3067
+ .contract-editor-bar .primary:hover { background: var(--accent-bright); border-color: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow); }
3068
+ .contract-editor-bar .primary:disabled { opacity: 0.6; cursor: default; background: var(--surface-2); border-color: var(--border-strong); color: var(--text-muted); box-shadow: none; }
3069
+ .contract-editor iframe { flex: 1; border: 0; width: 100%; min-height: 0; }
3070
+
3013
3071
  /* ===== MOBILE REFLOW =====
3014
3072
  Phone/touch access (e.g. over Tailscale). The desktop 3-column shell is wider than a phone
3015
3073
  and html,body{overflow:hidden} (top of file) clips the off-screen columns with no way to pan
package/dist/web/app.js CHANGED
@@ -339,7 +339,9 @@ function cardEl(id, ports) {
339
339
  const a = AGENTS[id];
340
340
  ports = ports || { in: 0, out: 0 };
341
341
  const div = document.createElement('div');
342
- div.className = 'agent-card';
342
+ // `frozen` = the node's output is pinned and Run skips it (aware app freeze). A quiet,
343
+ // inert card treatment + badge so it reads as "static / won't re-run" at a glance.
344
+ div.className = 'agent-card' + (a._frozen ? ' frozen' : '');
343
345
  div.dataset.agentId = id;
344
346
  div.draggable = true;
345
347
  const faved = state.favorites.some(f => f.id === id);
@@ -355,6 +357,7 @@ function cardEl(id, ports) {
355
357
  <div class="title">${a.title}</div>
356
358
  <div class="subtitle">${a.subtitle}</div>
357
359
  ${a._runtimeModel ? '<div class="rt-model-badge" title="This node calls a model at run time (vision.extract). Output is content-cached and sits behind an approve gate — review the extraction before any write.">⚡ calls a model at run time</div>' : ''}
360
+ ${a._frozen ? '<div class="frozen-badge" title="Frozen — its last result is pinned and Run skips this node. Right-click to unfreeze.">❄ Frozen</div>' : ''}
358
361
  <div class="blurb">${a.blurb}</div>
359
362
  <div class="footer-row">
360
363
  <span class="ports"><span class="ports-num">${ports.in}</span> in <span class="ports-arrow">·</span> <span class="ports-num">${ports.out}</span> out</span>
package/dist/web/aware.js CHANGED
@@ -28,6 +28,14 @@
28
28
  const $reportStop = document.getElementById('report-stop');
29
29
  const $stopRunBtn = document.getElementById('stop-run-btn');
30
30
 
31
+ // Contract editor — a dedicated full-window surface for an editable typed
32
+ // contract (steel takeoff, …). Unlike the opaque report viewer, this is a
33
+ // same-origin floless page (the iframe talks to /api/contract directly). The
34
+ // type→view registry lives in renderers.js (a global, loaded before this file).
35
+ const $contractEditor = document.getElementById('contract-editor');
36
+ const $contractEditorFrame = document.getElementById('contract-editor-frame');
37
+ const $contractEditorTitle = document.getElementById('contract-editor-title');
38
+
31
39
  // One persistent array the inspect Execution tab reads; we mutate in place so
32
40
  // every AGENTS[node].execution reference stays valid across re-renders.
33
41
  const liveTrace = [];
@@ -286,6 +294,7 @@
286
294
  return {
287
295
  _mode: n.mode,
288
296
  _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
297
+ _frozen: !!n.frozen, // source node carries a `frozen:` block → card badge + Unfreeze menu
289
298
  icon: iconFor(n.agent),
290
299
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
291
300
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
@@ -967,6 +976,15 @@
967
976
  }
968
977
  if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
969
978
  if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
979
+ const node = app && app.nodes.find((n) => n.id === id);
980
+ const contractType = node && window.contractTypeOf && window.contractTypeOf(node);
981
+ // Only offer the action when a renderer is actually registered for the type — otherwise
982
+ // openContractEditor() returns false and the click would silently do nothing.
983
+ if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
984
+ addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
985
+ addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
986
+ card.dataset.tip = 'Double-click to open the contract editor';
987
+ }
970
988
  });
971
989
  }
972
990
 
@@ -1319,6 +1337,186 @@
1319
1337
  }
1320
1338
  }
1321
1339
 
1340
+ // ── Contract editor (tier-1 typed, editable contract) ───────────────────────
1341
+ // Open the dedicated full-window editor for a node that emits a known contract
1342
+ // type. Returns true if a renderer exists (so the caller can stop here instead
1343
+ // of falling through to the report viewer); false → unknown type, caller continues.
1344
+ function openContractEditor(appId, type) {
1345
+ const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1346
+ if (!r) return false;
1347
+ $contractEditorTitle.replaceChildren(
1348
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1349
+ Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
1350
+ );
1351
+ $contractEditorFrame.src = r.editorUrl(appId);
1352
+ $contractEditor.hidden = false;
1353
+ return true;
1354
+ }
1355
+ function closeContractEditor() {
1356
+ $contractEditor.hidden = true;
1357
+ $contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
1358
+ }
1359
+ document.getElementById('contract-editor-close').onclick = closeContractEditor;
1360
+
1361
+ // Export 3D ▸ — render the approved takeoff as an interactive 3D model. Calls the server's
1362
+ // export-3d (transform → companion viewer-3d app → compile → run) and paints the returned
1363
+ // interactive report in the same viewer a normal run uses. api() throws on any failure (non-2xx
1364
+ // OR in-band ok:false), so one catch covers "no contract", "all RFI", and compile/render errors.
1365
+ async function exportContract3d(appId) {
1366
+ const app = currentId && apps.get(currentId);
1367
+ if (!app) return;
1368
+ $reportTitle.textContent = `3D Viewer · ${app.displayName}`;
1369
+ $reportSub.textContent = 'Rendering the 3D model from the approved takeoff…';
1370
+ $reportModal.dataset.nodeId = appId;
1371
+ showModal($reportModal);
1372
+ clearReportFrames(); // reset both frames + the ↗ Open buffer so a failure can't show a stale report
1373
+ $reportFrame.hidden = true; $reportFrame3d.hidden = true;
1374
+ $reportOverlay.hidden = false;
1375
+ $reportOverlay.replaceChildren(mkEl('div', 'spinner'), mkEl('div', null, 'Rendering 3D model…'));
1376
+ try {
1377
+ const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-3d', { method: 'POST' });
1378
+ const html = res.report && res.report.html;
1379
+ if (!html) { $reportOverlay.innerHTML = '<div>The 3D render produced no output. Check the Execution tab.</div>'; return; }
1380
+ // Partial render: members with no resolvable AISC size were dropped server-side (res.skipped) —
1381
+ // surface that, never let an incomplete model read as complete.
1382
+ const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1383
+ $reportSub.textContent = (dropped ? `${dropped} member(s) not shown (no AISC size — RFI). ` : '')
1384
+ + 'Live 3D view from the approved takeoff — drag to orbit, scroll to zoom, click an element to inspect.';
1385
+ if (dropped) showToast(`3D: ${dropped} member(s) skipped — no resolvable AISC size`, 'info');
1386
+ paintReport(html, true);
1387
+ lastReportByApp.set(currentId, { nodeId: res.report.nodeId, label: '3D', html, interactive: true });
1388
+ $reportModal.dataset.html = '1';
1389
+ } catch (e) {
1390
+ const msg = e && e.message ? e.message : String(e);
1391
+ $reportOverlay.innerHTML = `<div>3D export failed: ${escapeHtml(msg)}</div>`;
1392
+ showToast('3D export failed: ' + msg, 'warn');
1393
+ }
1394
+ }
1395
+
1396
+ // ── Right-click Freeze / Unfreeze a node (aware-aeco#261) ─────────────────────
1397
+ // A general per-node toggle: right-click a canvas node → Freeze output (Run then
1398
+ // REPLAYS the node's last result instead of re-running its agent — so e.g. a
1399
+ // drawing-reader node won't re-read & wipe the user's edits, "whatever's there is
1400
+ // there") or Unfreeze (runs normally again). Needs AWARE ≥ 0.77; on an older runtime
1401
+ // the menu simply doesn't appear and the native menu passes through (the app keeps
1402
+ // AWARE at latest, so that's rare/transient — never a dead/disabled control).
1403
+ let _freezeMenuEl = null, _freezeMenuEsc = null, _freezeMenuOutside = null;
1404
+
1405
+ // numeric major.minor.patch compare; an empty/unparseable version sorts BELOW (-1),
1406
+ // so a missing awareVersion safely reads as "freeze unsupported". `Number` (not parseInt)
1407
+ // is strict — a prerelease segment like "0-rc1" → NaN → unsupported (defer to the server
1408
+ // backstop), never silently treated as the release.
1409
+ function cmpSemver(a, b) {
1410
+ const pa = String(a || '').split('.').map((x) => Number(x));
1411
+ const pb = String(b || '').split('.').map((x) => Number(x));
1412
+ if (pa.length < 3 || pa.some(Number.isNaN)) return -1;
1413
+ for (let i = 0; i < 3; i++) {
1414
+ const d = (pa[i] || 0) - (pb[i] || 0);
1415
+ if (d !== 0) return d < 0 ? -1 : 1;
1416
+ }
1417
+ return 0;
1418
+ }
1419
+ function awareSupportsFreeze() { return cmpSemver(awareRuntimeVersion, '0.77.0') >= 0; }
1420
+
1421
+ function closeNodeMenu() {
1422
+ if (_freezeMenuEsc) { document.removeEventListener('keydown', _freezeMenuEsc, true); _freezeMenuEsc = null; }
1423
+ if (_freezeMenuOutside) { document.removeEventListener('mousedown', _freezeMenuOutside, true); _freezeMenuOutside = null; }
1424
+ if (_freezeMenuEl) { _freezeMenuEl.remove(); _freezeMenuEl = null; }
1425
+ }
1426
+
1427
+ // Build + open the cursor-anchored menu over a node card. Reuses the hamburger
1428
+ // `.menu`/`.menu-item` vocabulary (+ a `.context-menu` width modifier) so it matches
1429
+ // the existing popover language; dismiss mirrors openNotesPopover (Escape + click-away).
1430
+ function openNodeMenu(card, x, y) {
1431
+ closeNodeMenu();
1432
+ const nodeId = card.dataset.agentId;
1433
+ if (!nodeId) return;
1434
+ const node = AGENTS[nodeId];
1435
+ const frozen = !!(node && node._frozen);
1436
+ // A 4-line snowflake glyph as a stroke SVG — matches `.menu-icon svg` (Lucide family).
1437
+ const ice = '<span class="menu-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.6" y1="4.6" x2="19.4" y2="19.4"/><line x1="19.4" y1="4.6" x2="4.6" y2="19.4"/></svg></span>';
1438
+ const menu = document.createElement('div');
1439
+ menu.className = 'menu context-menu show';
1440
+ menu.setAttribute('role', 'menu');
1441
+ menu.setAttribute('aria-label', 'Node actions');
1442
+ menu.innerHTML =
1443
+ `<div class="menu-head" role="none">${escapeHtml(nodeId)}</div>`
1444
+ + `<div class="menu-divider"></div>`
1445
+ + `<button type="button" class="menu-item" role="menuitem">${ice}<span class="menu-label">${frozen ? 'Unfreeze' : 'Freeze output'}</span></button>`;
1446
+ document.body.appendChild(menu);
1447
+ // Anchor at the pointer, then clamp so a card near the right/bottom edge can't push
1448
+ // the menu off-screen (same intent as openNotesPopover's edge-clamp).
1449
+ const r = menu.getBoundingClientRect();
1450
+ menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + 'px';
1451
+ menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + 'px';
1452
+ const item = menu.querySelector('.menu-item');
1453
+ item.onclick = () => { closeNodeMenu(); toggleFreeze(nodeId, frozen); };
1454
+ try { item.focus(); } catch { /* container handles focus */ }
1455
+ _freezeMenuEl = menu;
1456
+ _freezeMenuEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeNodeMenu(); try { card.focus(); } catch { /* card not focusable */ } } };
1457
+ document.addEventListener('keydown', _freezeMenuEsc, true);
1458
+ _freezeMenuOutside = (e) => { if (_freezeMenuEl && !_freezeMenuEl.contains(e.target)) closeNodeMenu(); };
1459
+ document.addEventListener('mousedown', _freezeMenuOutside, true);
1460
+ }
1461
+
1462
+ // Toggle a node's frozen state via the server (which shells `aware app freeze/unfreeze`
1463
+ // + recompiles), then re-read the app so the badge + state repaint. api() throws on any
1464
+ // failure (non-2xx OR in-band ok:false) with the server's plain-English message — e.g.
1465
+ // "No output to freeze — run this node at least once first." — so one catch covers it.
1466
+ async function toggleFreeze(nodeId, currentlyFrozen) {
1467
+ if (!currentId) return;
1468
+ const verb = currentlyFrozen ? 'unfreeze' : 'freeze';
1469
+ try {
1470
+ await api(`/api/app/${encodeURIComponent(currentId)}/${verb}`, { method: 'POST', body: JSON.stringify({ nodeId }) });
1471
+ await loadApp(currentId); // re-read → frozen badge/state repaints
1472
+ showToast(currentlyFrozen
1473
+ ? 'Node unfrozen — it will run normally.'
1474
+ : 'Output frozen — this node will replay its last result on the next run.',
1475
+ currentlyFrozen ? 'info' : 'ok');
1476
+ } catch (e) {
1477
+ // Surface the real aware reason the route attached as body.reason — otherwise the friendly
1478
+ // lead hides the actual cause. First non-empty line, capped, keeps the warn toast sane.
1479
+ const lead = (e && e.message) || `Could not ${verb} this node.`;
1480
+ const raw = e && e.body && typeof e.body.reason === 'string' ? e.body.reason.trim() : '';
1481
+ const reason = raw ? (raw.split('\n').find((l) => l.trim()) || '').trim().slice(0, 200) : '';
1482
+ showToast(reason ? `${lead} — ${reason}` : lead, 'warn');
1483
+ }
1484
+ }
1485
+
1486
+ // Right-click a node card → the Freeze/Unfreeze menu. preventDefault is scoped to cards
1487
+ // (right-click stays normal everywhere else), and only when AWARE supports it — on an
1488
+ // older runtime the native menu passes through, so there's no dead interaction.
1489
+ document.addEventListener('contextmenu', (e) => {
1490
+ const card = e.target.closest && e.target.closest('.agent-card');
1491
+ if (!card || !awareSupportsFreeze()) return;
1492
+ e.preventDefault();
1493
+ openNodeMenu(card, e.clientX, e.clientY);
1494
+ });
1495
+
1496
+ // Approve & bake — freezes the edited contract into the .lock, arming Run.
1497
+ // Flushes the editor's pending debounce save first so Approve always bakes the
1498
+ // latest in-memory edits, not a stale snapshot. api() throws on non-2xx, so
1499
+ // any flush or bake failure surfaces through the catch as a warn toast.
1500
+ document.getElementById('contract-editor-approve').onclick = async () => {
1501
+ const appId = currentId;
1502
+ if (!appId) return;
1503
+ const btn = document.getElementById('contract-editor-approve');
1504
+ const prev = btn.textContent;
1505
+ btn.disabled = true; btn.textContent = '⊙ Baking…';
1506
+ try {
1507
+ const frameWin = document.getElementById('contract-editor-frame').contentWindow;
1508
+ if (frameWin && typeof frameWin.flushContract === 'function') {
1509
+ await frameWin.flushContract(); // ensure the latest edits are on the server before baking
1510
+ }
1511
+ await api(`/api/contract/${encodeURIComponent(appId)}/approve`, { method: 'POST' });
1512
+ showToast('Approved — lock baked. Run is armed.', 'ok');
1513
+ } catch (e) {
1514
+ showToast((e && e.message) || 'Approve failed', 'warn');
1515
+ } finally {
1516
+ btn.disabled = false; btn.textContent = prev;
1517
+ }
1518
+ };
1519
+
1322
1520
  // The dedicated RUN: executes the app for real (simulate:false) with the
1323
1521
  // current inputs, renders + caches the HTML the report node returned. The UI
1324
1522
  // never builds the HTML — it relays exactly what the exec node produced.
@@ -1710,14 +1908,19 @@
1710
1908
  });
1711
1909
  }
1712
1910
 
1713
- // Double-click a node on the canvas: the report node LOAD the last report (no
1714
- // run); the input node open the styled inputs dialog. Delegated + bound once;
1715
- // survives every renderTopology that rebuilds the cards.
1911
+ // Double-click a node on the canvas: a node that emits an editable typed contract
1912
+ // open the dedicated full-window editor; the report node LOAD the last report
1913
+ // (no run); the input node → open the styled inputs dialog. Delegated + bound
1914
+ // once; survives every renderTopology that rebuilds the cards.
1716
1915
  $topology.addEventListener('dblclick', (e) => {
1717
1916
  const card = e.target.closest('.agent-card');
1718
1917
  if (!card) return;
1719
1918
  const id = card.dataset.agentId;
1720
1919
  if (!id) return;
1920
+ const app = currentId && apps.get(currentId);
1921
+ const node = app && (app.nodes || []).find((n) => n.id === id);
1922
+ const ct = window.contractTypeOf && window.contractTypeOf(node);
1923
+ if (ct && openContractEditor(currentId, ct)) return;
1721
1924
  if (id === reportNodeId()) showReport(id);
1722
1925
  else if (id === inputNodeId()) openInputsDialog();
1723
1926
  });
@@ -3182,6 +3385,8 @@
3182
3385
  }
3183
3386
  let shownVersion = false;
3184
3387
  let loadedAppVersion = null; // the build this tab is running against (first health wins)
3388
+ let awareRuntimeVersion = null; // installed AWARE version (from /api/health) — gates the freeze menu
3389
+ let freezeHintAppended = false; // append the right-click-to-freeze canvas hint once, when supported
3185
3390
  function startHealthPoll() {
3186
3391
  const tick = async () => {
3187
3392
  let ok = false;
@@ -3224,6 +3429,15 @@
3224
3429
  // After the build version is stamped, reveal the relaunch-surviving what's-new
3225
3430
  // panel iff this is the build we just self-updated into (guarded to once).
3226
3431
  maybeShowWhatsNew();
3432
+ // Track the installed AWARE runtime version so the right-click freeze menu can
3433
+ // gate itself (needs ≥ 0.77) and the canvas hint can advertise it once available.
3434
+ if (h && h.awareVersion) {
3435
+ awareRuntimeVersion = h.awareVersion;
3436
+ if (!freezeHintAppended && awareSupportsFreeze()) {
3437
+ const ch = document.getElementById('canvas-hint');
3438
+ if (ch) { ch.textContent += ' Right-click a node to freeze its output.'; freezeHintAppended = true; }
3439
+ }
3440
+ }
3227
3441
  const wv = document.getElementById('aware-version');
3228
3442
  if (wv && h && h.awareVersion) {
3229
3443
  const next = 'AWARE ' + h.awareVersion;