@floless/app 0.27.0 → 0.29.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
@@ -344,6 +344,9 @@
344
344
  overflow: hidden;
345
345
  min-width: 0;
346
346
  position: relative;
347
+ /* Touch: the canvas is a one-finger-pan + pinch-zoom surface on phones (handled in app.js);
348
+ touch-action:none stops the browser claiming the gesture first. .dashboard re-enables scroll. */
349
+ touch-action: none;
347
350
  /* The whole canvas is a pannable infinite surface (drag the background / middle-mouse),
348
351
  so the grab affordance covers the entire grid-area — not just .topology, which is
349
352
  sized around the node cards and left distant background areas as a plain arrow (#115).
@@ -2900,7 +2903,7 @@ body {
2900
2903
  /* In dashboard view the pannable topology is hidden, so the canvas isn't a drag surface —
2901
2904
  drop the grab affordance the .canvas rule sets for the normal view (#115 follow-up). */
2902
2905
  .canvas.view-dashboard { cursor: default; }
2903
- .dashboard { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 24px; }
2906
+ .dashboard { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 24px; touch-action: pan-y; }
2904
2907
  .dashboard[hidden] { display: none; }
2905
2908
 
2906
2909
  /* Notices above the panels: dim note (degraded validation / warnings) and the
@@ -3006,3 +3009,50 @@ body {
3006
3009
 
3007
3010
  /* Inspect-tab panels reuse the same block styles inside the inspect body. */
3008
3011
  .ext-inspect { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 0; }
3012
+
3013
+ /* ── Contract editor overlay ────────────────────────────────────────────────
3014
+ Full-window surface for an editable typed contract (steel takeoff, …).
3015
+ Same-origin iframe, so the editor page calls /api/contract directly.
3016
+ z-index 60 sits above the 3-col app (z 1–8) and below any modal backdrops (z 70+). */
3017
+ .contract-editor {
3018
+ position: fixed; inset: 0; z-index: 60;
3019
+ display: flex; flex-direction: column;
3020
+ background: var(--bg);
3021
+ }
3022
+ .contract-editor[hidden] { display: none; }
3023
+ .contract-editor-bar {
3024
+ display: flex; align-items: center; gap: 12px;
3025
+ padding: 8px 14px;
3026
+ background: var(--surface-2);
3027
+ border-bottom: 1px solid var(--border);
3028
+ flex: none;
3029
+ }
3030
+ .contract-editor-title {
3031
+ color: var(--text-muted);
3032
+ font-size: 13px;
3033
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3034
+ }
3035
+ /* The Approve button reuses the same .primary recipe as modal primary actions. */
3036
+ .contract-editor-bar .primary {
3037
+ background: var(--accent); color: #fff; border: 1px solid var(--accent);
3038
+ padding: 6px 14px; border-radius: 6px; font-size: 12.5px; font-weight: 600; cursor: pointer;
3039
+ }
3040
+ .contract-editor-bar .primary:hover { background: var(--accent-bright); border-color: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow); }
3041
+ .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; }
3042
+ .contract-editor iframe { flex: 1; border: 0; width: 100%; min-height: 0; }
3043
+
3044
+ /* ===== MOBILE REFLOW =====
3045
+ Phone/touch access (e.g. over Tailscale). The desktop 3-column shell is wider than a phone
3046
+ and html,body{overflow:hidden} (top of file) clips the off-screen columns with no way to pan
3047
+ to them — single-finger AND pinch do nothing. On narrow screens, WITHIN the locked skin:
3048
+ (1) let the shell scroll so nothing is ever unreachable;
3049
+ (2) collapse both side panels by default (see app.js) — canvas full-width, tap the existing
3050
+ 48px stubs to reveal chat/inspect one at a time;
3051
+ (3) let the header wrap and the workflow picker shrink so it fits one phone-width row.
3052
+ Guarded by the media query → desktop layout is 100% untouched. No new fonts/colors/components. */
3053
+ @media (max-width: 760px) {
3054
+ html, body { overflow: auto; }
3055
+ .app { height: auto; min-height: 100vh; grid-template-rows: auto 1fr 44px; }
3056
+ header { flex-wrap: wrap; gap: 8px; padding: 8px 14px; }
3057
+ select, .wf-combo { min-width: 0; }
3058
+ }
package/dist/web/app.js CHANGED
@@ -31,6 +31,13 @@ function loadState() {
31
31
  if (c.left !== undefined) state.collapse.left = c.left;
32
32
  if (c.right !== undefined) state.collapse.right = c.right;
33
33
  } catch {}
34
+ // Mobile/touch: start with both side panels collapsed so the canvas is full-width and the
35
+ // desktop 3-column shell fits a phone. In-memory only — NOT persisted, so it never clobbers
36
+ // the desktop collapse preference. The user can still tap the stubs to reveal chat/inspect.
37
+ if (window.matchMedia('(max-width: 760px)').matches) {
38
+ state.collapse.left = true;
39
+ state.collapse.right = true;
40
+ }
34
41
  }
35
42
  function saveFavs() {
36
43
  document.getElementById('fav-count').textContent = state.favorites.length;
@@ -1448,6 +1455,54 @@ window.addEventListener('mouseup', () => {
1448
1455
  });
1449
1456
  $canvasEl.addEventListener('auxclick', (e) => { if (e.button === 1) e.preventDefault(); });
1450
1457
 
1458
+ // ----- Touch: one-finger pan + two-finger pinch-zoom (phones). The mouse handlers above are
1459
+ // untouched (touch events never fire from a mouse), so desktop is unaffected. touch-action:none
1460
+ // on .canvas (CSS) stops the browser hijacking the gesture and suppresses synthetic mouse events.
1461
+ let tPan = false, tPinch = false, tFromX = 0, tFromY = 0, tBaseX = 0, tBaseY = 0, pinchDist0 = 0, pinchZoom0 = 1;
1462
+ const pinchSpan = (ts) => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
1463
+ $canvasEl.addEventListener('touchstart', (e) => {
1464
+ if (e.touches.length === 2) {
1465
+ e.preventDefault();
1466
+ tPinch = true; tPan = false;
1467
+ pinchDist0 = pinchSpan(e.touches) || 1; pinchZoom0 = zoom;
1468
+ } else if (e.touches.length === 1) {
1469
+ // Let taps on interactive chrome through (node select, zoom toolbar, fav-bar, find, dashboard).
1470
+ if (e.target.closest('.agent-card, .canvas-toolbar, .fav-bar, .find-overlay, .dashboard')) return;
1471
+ e.preventDefault();
1472
+ tPan = true; tPinch = false;
1473
+ tFromX = e.touches[0].clientX; tFromY = e.touches[0].clientY;
1474
+ tBaseX = panX; tBaseY = panY;
1475
+ $canvasEl.classList.add('panning');
1476
+ }
1477
+ }, { passive: false });
1478
+ $canvasEl.addEventListener('touchmove', (e) => {
1479
+ if (tPinch && e.touches.length === 2) {
1480
+ e.preventDefault();
1481
+ const ratio = pinchSpan(e.touches) / pinchDist0;
1482
+ zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, pinchZoom0 * ratio));
1483
+ autoFit = false;
1484
+ applyTransform();
1485
+ } else if (tPan && e.touches.length === 1) {
1486
+ e.preventDefault();
1487
+ panX = tBaseX + (e.touches[0].clientX - tFromX);
1488
+ panY = tBaseY + (e.touches[0].clientY - tFromY);
1489
+ autoFit = false;
1490
+ applyTransform();
1491
+ }
1492
+ }, { passive: false });
1493
+ function endTouch(e) {
1494
+ if (e.touches.length === 0) {
1495
+ tPan = false; tPinch = false; $canvasEl.classList.remove('panning');
1496
+ } else if (e.touches.length === 1 && tPinch) {
1497
+ // 2->1 finger: hand off from pinch to pan without a jump.
1498
+ tPinch = false; tPan = true;
1499
+ tFromX = e.touches[0].clientX; tFromY = e.touches[0].clientY;
1500
+ tBaseX = panX; tBaseY = panY;
1501
+ }
1502
+ }
1503
+ $canvasEl.addEventListener('touchend', endTouch);
1504
+ $canvasEl.addEventListener('touchcancel', endTouch);
1505
+
1451
1506
  // Keep the auto-fitted view fitted as the canvas resizes (a panel toggling/dragging,
1452
1507
  // the window resizing) — but never override a view the user has zoomed/panned. A
1453
1508
  // trailing debounce fires ONE clean re-fit after the resize settles (rather than on
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 = [];
@@ -967,6 +975,15 @@
967
975
  }
968
976
  if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
969
977
  if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
978
+ const node = app && app.nodes.find((n) => n.id === id);
979
+ const contractType = node && window.contractTypeOf && window.contractTypeOf(node);
980
+ // Only offer the action when a renderer is actually registered for the type — otherwise
981
+ // openContractEditor() returns false and the click would silently do nothing.
982
+ if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
983
+ addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
984
+ addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
985
+ card.dataset.tip = 'Double-click to open the contract editor';
986
+ }
970
987
  });
971
988
  }
972
989
 
@@ -1319,6 +1336,86 @@
1319
1336
  }
1320
1337
  }
1321
1338
 
1339
+ // ── Contract editor (tier-1 typed, editable contract) ───────────────────────
1340
+ // Open the dedicated full-window editor for a node that emits a known contract
1341
+ // type. Returns true if a renderer exists (so the caller can stop here instead
1342
+ // of falling through to the report viewer); false → unknown type, caller continues.
1343
+ function openContractEditor(appId, type) {
1344
+ const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1345
+ if (!r) return false;
1346
+ $contractEditorTitle.replaceChildren(
1347
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1348
+ Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
1349
+ );
1350
+ $contractEditorFrame.src = r.editorUrl(appId);
1351
+ $contractEditor.hidden = false;
1352
+ return true;
1353
+ }
1354
+ function closeContractEditor() {
1355
+ $contractEditor.hidden = true;
1356
+ $contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
1357
+ }
1358
+ document.getElementById('contract-editor-close').onclick = closeContractEditor;
1359
+
1360
+ // Export 3D ▸ — render the approved takeoff as an interactive 3D model. Calls the server's
1361
+ // export-3d (transform → companion viewer-3d app → compile → run) and paints the returned
1362
+ // interactive report in the same viewer a normal run uses. api() throws on any failure (non-2xx
1363
+ // OR in-band ok:false), so one catch covers "no contract", "all RFI", and compile/render errors.
1364
+ async function exportContract3d(appId) {
1365
+ const app = currentId && apps.get(currentId);
1366
+ if (!app) return;
1367
+ $reportTitle.textContent = `3D Viewer · ${app.displayName}`;
1368
+ $reportSub.textContent = 'Rendering the 3D model from the approved takeoff…';
1369
+ $reportModal.dataset.nodeId = appId;
1370
+ showModal($reportModal);
1371
+ clearReportFrames(); // reset both frames + the ↗ Open buffer so a failure can't show a stale report
1372
+ $reportFrame.hidden = true; $reportFrame3d.hidden = true;
1373
+ $reportOverlay.hidden = false;
1374
+ $reportOverlay.replaceChildren(mkEl('div', 'spinner'), mkEl('div', null, 'Rendering 3D model…'));
1375
+ try {
1376
+ const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-3d', { method: 'POST' });
1377
+ const html = res.report && res.report.html;
1378
+ if (!html) { $reportOverlay.innerHTML = '<div>The 3D render produced no output. Check the Execution tab.</div>'; return; }
1379
+ // Partial render: members with no resolvable AISC size were dropped server-side (res.skipped) —
1380
+ // surface that, never let an incomplete model read as complete.
1381
+ const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1382
+ $reportSub.textContent = (dropped ? `${dropped} member(s) not shown (no AISC size — RFI). ` : '')
1383
+ + 'Live 3D view from the approved takeoff — drag to orbit, scroll to zoom, click an element to inspect.';
1384
+ if (dropped) showToast(`3D: ${dropped} member(s) skipped — no resolvable AISC size`, 'info');
1385
+ paintReport(html, true);
1386
+ lastReportByApp.set(currentId, { nodeId: res.report.nodeId, label: '3D', html, interactive: true });
1387
+ $reportModal.dataset.html = '1';
1388
+ } catch (e) {
1389
+ const msg = e && e.message ? e.message : String(e);
1390
+ $reportOverlay.innerHTML = `<div>3D export failed: ${escapeHtml(msg)}</div>`;
1391
+ showToast('3D export failed: ' + msg, 'warn');
1392
+ }
1393
+ }
1394
+
1395
+ // Approve & bake — freezes the edited contract into the .lock, arming Run.
1396
+ // Flushes the editor's pending debounce save first so Approve always bakes the
1397
+ // latest in-memory edits, not a stale snapshot. api() throws on non-2xx, so
1398
+ // any flush or bake failure surfaces through the catch as a warn toast.
1399
+ document.getElementById('contract-editor-approve').onclick = async () => {
1400
+ const appId = currentId;
1401
+ if (!appId) return;
1402
+ const btn = document.getElementById('contract-editor-approve');
1403
+ const prev = btn.textContent;
1404
+ btn.disabled = true; btn.textContent = '⊙ Baking…';
1405
+ try {
1406
+ const frameWin = document.getElementById('contract-editor-frame').contentWindow;
1407
+ if (frameWin && typeof frameWin.flushContract === 'function') {
1408
+ await frameWin.flushContract(); // ensure the latest edits are on the server before baking
1409
+ }
1410
+ await api(`/api/contract/${encodeURIComponent(appId)}/approve`, { method: 'POST' });
1411
+ showToast('Approved — lock baked. Run is armed.', 'ok');
1412
+ } catch (e) {
1413
+ showToast((e && e.message) || 'Approve failed', 'warn');
1414
+ } finally {
1415
+ btn.disabled = false; btn.textContent = prev;
1416
+ }
1417
+ };
1418
+
1322
1419
  // The dedicated RUN: executes the app for real (simulate:false) with the
1323
1420
  // current inputs, renders + caches the HTML the report node returned. The UI
1324
1421
  // never builds the HTML — it relays exactly what the exec node produced.
@@ -1710,14 +1807,19 @@
1710
1807
  });
1711
1808
  }
1712
1809
 
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.
1810
+ // Double-click a node on the canvas: a node that emits an editable typed contract
1811
+ // open the dedicated full-window editor; the report node LOAD the last report
1812
+ // (no run); the input node → open the styled inputs dialog. Delegated + bound
1813
+ // once; survives every renderTopology that rebuilds the cards.
1716
1814
  $topology.addEventListener('dblclick', (e) => {
1717
1815
  const card = e.target.closest('.agent-card');
1718
1816
  if (!card) return;
1719
1817
  const id = card.dataset.agentId;
1720
1818
  if (!id) return;
1819
+ const app = currentId && apps.get(currentId);
1820
+ const node = app && (app.nodes || []).find((n) => n.id === id);
1821
+ const ct = window.contractTypeOf && window.contractTypeOf(node);
1822
+ if (ct && openContractEditor(currentId, ct)) return;
1721
1823
  if (id === reportNodeId()) showReport(id);
1722
1824
  else if (id === inputNodeId()) openInputsDialog();
1723
1825
  });
@@ -320,6 +320,19 @@
320
320
  </div>
321
321
  </div>
322
322
 
323
+ <!-- Dedicated full-window surface for a contract editor (steel takeoff, …).
324
+ Same-origin iframe so the editor reads/writes /api/contract directly
325
+ (unlike the opaque 3D viewer which uses a data: URL and allow-scripts only). -->
326
+ <div id="contract-editor" class="contract-editor" hidden>
327
+ <div class="contract-editor-bar">
328
+ <button id="contract-editor-close" type="button">✕ Close</button>
329
+ <span id="contract-editor-title" class="contract-editor-title"></span>
330
+ <span style="flex:1"></span>
331
+ <button id="contract-editor-approve" type="button" class="primary">✓ Approve &amp; bake lock</button>
332
+ </div>
333
+ <iframe id="contract-editor-frame" title="Contract editor"></iframe>
334
+ </div>
335
+
323
336
  <!-- HTML Viewer — renders the report HTML an exec node returned, in-app.
324
337
  Double-click a report node on the canvas to run it and open this. -->
325
338
  <div class="modal-backdrop report-backdrop" id="report-modal">
@@ -672,6 +685,7 @@
672
685
  </div>
673
686
  </div>
674
687
  <script src="app.js"></script>
688
+ <script src="renderers.js"></script>
675
689
  <script src="aware.js"></script>
676
690
  <script src="panels.js"></script>
677
691
  </body>
@@ -0,0 +1,25 @@
1
+ /* ============================================================================
2
+ * renderers.js — contract-type → editor-view registry.
3
+ *
4
+ * aware.js is an IIFE (not an ES module), so this file exposes globals too.
5
+ * Include this via <script src="renderers.js"> BEFORE aware.js in index.html.
6
+ *
7
+ * Tier 0: HTML-Viewer (read-only {html}) — rendered by the existing report modal.
8
+ * Tier 1: typed, editable contract — rendered by a dedicated editor page (this file).
9
+ * ========================================================================== */
10
+
11
+ // contract type → descriptor for the editor surface.
12
+ window.CONTRACT_RENDERERS = {
13
+ 'steel.takeoff/v1': {
14
+ editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
15
+ },
16
+ };
17
+
18
+ // Return the contract type string declared on a node, or null when none.
19
+ // A node "emits" a contract type when its config carries a `contract` field,
20
+ // or when its agent is the known steel-takeoff agent.
21
+ window.contractTypeOf = function contractTypeOf(node) {
22
+ return (node && node.config && node.config.contract) ||
23
+ // TODO: drop this agent-name fallback once every steel-takeoff node carries config.contract.
24
+ (node && node.agent === 'steel-takeoff-us' ? 'steel.takeoff/v1' : null);
25
+ };