@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.
- package/dist/floless-server.cjs +700 -271
- package/dist/schemas/steel.takeoff.v1.schema.json +148 -0
- package/dist/skills/floless-app-tweak-contract/SKILL.md +111 -0
- package/dist/web/app.css +51 -1
- package/dist/web/app.js +55 -0
- package/dist/web/aware.js +105 -3
- package/dist/web/index.html +14 -0
- package/dist/web/renderers.js +25 -0
- package/dist/web/steel-editor.html +1019 -0
- package/package.json +1 -1
|
@@ -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:
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
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
|
});
|
package/dist/web/index.html
CHANGED
|
@@ -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 & 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
|
+
};
|