@floless/app 0.31.3 → 0.32.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.
@@ -52835,7 +52835,7 @@ function appVersion() {
52835
52835
  return resolveVersion({
52836
52836
  isSea: isSea2(),
52837
52837
  sqVersionXml: readSqVersionXml(),
52838
- define: true ? "0.31.3" : void 0,
52838
+ define: true ? "0.32.0" : void 0,
52839
52839
  pkgVersion: readPkgVersion()
52840
52840
  });
52841
52841
  }
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
52845
52845
  return "dev";
52846
52846
  }
52847
52847
  function appChannel() {
52848
- return resolveChannel({ isSea: isSea2(), define: true ? "0.31.3" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.32.0" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
@@ -53698,6 +53698,24 @@ function writeContract(appId, doc2) {
53698
53698
  (0, import_node_fs16.writeFileSync)(p, JSON.stringify(doc2));
53699
53699
  }
53700
53700
 
53701
+ // contract-resolve.ts
53702
+ function readContractForApp(appId, readAppFn = readApp) {
53703
+ const draft = readContract(appId);
53704
+ if (draft != null) return draft;
53705
+ let app;
53706
+ try {
53707
+ app = readAppFn(appId);
53708
+ } catch {
53709
+ return null;
53710
+ }
53711
+ const node = app.nodes.find((n) => n.config["contract"] === "steel.takeoff/v1");
53712
+ const t = node?.config["takeoff"];
53713
+ if (t == null || typeof t !== "object" || Array.isArray(t) || Object.keys(t).length === 0) {
53714
+ return null;
53715
+ }
53716
+ return t;
53717
+ }
53718
+
53701
53719
  // contract-bake.ts
53702
53720
  var import_node_fs17 = require("node:fs");
53703
53721
  var import_yaml4 = __toESM(require_dist6(), 1);
@@ -55854,6 +55872,8 @@ var PRODUCT_SKILLS = [
55854
55872
  // author event-driven ("on trigger") routines
55855
55873
  "floless-app-steel-from-drawings",
55856
55874
  // read a steel drawing into an interactive 3D model (bake the viewer-3d scene)
55875
+ "floless-app-steel-takeoff",
55876
+ // read a steel drawing into an editable steel.takeoff/v1 contract (the Steel Takeoff reader)
55857
55877
  "floless-app-tweak-contract",
55858
55878
  // handle a tweak-contract request from the steel-takeoff contract editor (Slice 2 AI round-trip)
55859
55879
  "floless-app-ui",
@@ -58147,7 +58167,7 @@ async function startServer() {
58147
58167
  return { ok: true, app: readApp(req.params.id) };
58148
58168
  });
58149
58169
  app.get("/api/contract/:appId", async (req, reply) => {
58150
- const doc2 = readContract(req.params.appId);
58170
+ const doc2 = readContractForApp(req.params.appId);
58151
58171
  if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract for this app yet" });
58152
58172
  return doc2;
58153
58173
  });
@@ -58732,6 +58752,7 @@ async function startServer() {
58732
58752
  { bodyLimit: 25 * 1024 * 1024 },
58733
58753
  async (req, reply) => {
58734
58754
  const { appId, inputName, instruction, snapshots } = req.body ?? {};
58755
+ const sourceName = typeof req.body?.sourceName === "string" ? req.body.sourceName : void 0;
58735
58756
  if (!appId) return reply.status(400).send({ ok: false, error: "appId required" });
58736
58757
  let decoded;
58737
58758
  try {
@@ -58740,7 +58761,7 @@ async function startServer() {
58740
58761
  return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
58741
58762
  }
58742
58763
  const request = addRequest(
58743
- { type: "rebake", appId, ...inputName ? { inputName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
58764
+ { type: "rebake", appId, ...inputName ? { inputName } : {}, ...sourceName ? { sourceName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
58744
58765
  decoded
58745
58766
  );
58746
58767
  broadcast({ type: "request-added", request });
@@ -23,6 +23,18 @@
23
23
  },
24
24
  "description": "sheet -> { detail number -> normalized [fx,fy] bubble anchor }."
25
25
  },
26
+ "source": {
27
+ "type": "object",
28
+ "description": "Provenance: where this takeoff was read from.",
29
+ "properties": {
30
+ "name": { "type": "string" },
31
+ "path": { "type": "string" },
32
+ "sheet": { "type": "string" },
33
+ "sha256": { "type": "string" },
34
+ "read_at": { "type": "string" }
35
+ },
36
+ "additionalProperties": false
37
+ },
26
38
  "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
27
39
  },
28
40
  "$defs": {
@@ -0,0 +1,266 @@
1
+ ---
2
+ name: floless-app-steel-takeoff
3
+ description: Read a structural steel drawing into an editable steel.takeoff/v1 contract in floless.app — the "Steel Takeoff" reader. Triggers on a pending floless `rebake` request for the `steel-takeoff` app (queued from its "Re-read & re-bake ▸" button), or asks like "read this framing plan into a takeoff", "turn this structural drawing into a takeoff", "bake my drawing into steel-takeoff", "extract members from this PDF". Teaches the host AI to READ a steel drawing (PDF/image) with its own vision at COMPOSE time, build a steel.takeoff/v1 contract (members + profiles + elevations + weights from AISC lookup), write it to the contract store, and hand control to the editor — no model in the run path, no API key.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
9
+
10
+ ## What this is
11
+
12
+ `steel-takeoff` is a FloLess app that turns a structural steel drawing into an editable
13
+ **takeoff contract** — members, profiles, AISC weights, T.O. elevations, connection detail refs
14
+ — rendered in the overlay editor, then baked at Approve into the app's `read` node.
15
+
16
+ The drawing is read **at compose time** with your own vision. There is **no model in the run
17
+ path and no API key** — the brain is you, in the terminal (the FloLess thin-UI contract). The
18
+ deterministic run only renders the approved result.
19
+
20
+ **Downstream outputs** (on the same canvas, triggered separately) are a 3D stick model
21
+ (`viewer-3d`, from the Approve bake), an IFC file, a Tekla bake, and a formatted BOM — but
22
+ this skill's job ends at producing and handing off the takeoff contract. Mention those as "next"
23
+ but do not attempt them here.
24
+
25
+ > **Pasted a request?** If the user pastes a message beginning with a `[floless-request
26
+ > type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
27
+ > via `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it
28
+ > when done.
29
+
30
+ ---
31
+
32
+ ## The loop
33
+
34
+ ### 1. Find the drawing
35
+
36
+ From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
37
+ `type:"rebake"` and `appId:"steel-takeoff"` carries `snapshots: ["<abs path>"]` (the drawing
38
+ file) and optionally `sourceName` (the original filename — use it for the `source.name`
39
+ provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
40
+ and proceed from that path.
41
+
42
+ ### 2. Read it with your own vision — the heavy pipeline
43
+
44
+ Follow the methodology condensed here (full detail + every gotcha in
45
+ `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
46
+
47
+ 1. **Sheet index.** If it's a multi-sheet PDF, read the index sheet (e.g. `S-100`) once with
48
+ vision to build a `sheet → page` map. Focus the takeoff on one or a few framing plan sheets
49
+ (Foundation, Floor, Roof); say which.
50
+ 2. **Tile the framing area** in display space (e.g. 4×3 grid, render scale ~2.0). Record each
51
+ tile's display origin and extent.
52
+ 3. **Geometry (exact).** `page.get_drawings()` → keep member-scale line segments (~8–45 ft) in
53
+ display coords, tagged H/V/D. These are the candidate members and the snap targets.
54
+ 4. **Labels (vision — parallel subagents).** Read section labels per tile; each subagent returns
55
+ **normalized fractions [0..1]** (not pixel coordinates — pixels from a subagent are
56
+ scale-ambiguous) plus orientation (`H`/`V`/`D`). Convert to display, dedup.
57
+ - **Skew bays get their OWN dedicated pass — they are the most-skipped members.** A skewed/angled
58
+ framing bay read together with the orthogonal grid loses its diagonal members (the orthogonal
59
+ pass "swallows" them, and free-hand label positions don't bind to the diagonal lines).
60
+ - **Reliable skew method = bind to the EXACT diagonal geometry, don't free-place.** This is the
61
+ technique that works (proven on S-202):
62
+ 1. From step 3, take the `D` member-scale segments (the exact diagonal geometry) and their
63
+ bounding box — that's the skew bay.
64
+ 2. Render that bbox at high DPI and **draw the `D` segments over it, each NUMBERED** at its
65
+ midpoint (red line + index 0..N).
66
+ 3. One vision pass over that numbered image maps **each segment index → its label** (chords are
67
+ typically the short collinear sub-segments = `W16X26`; the long perpendicular rungs =
68
+ `W21X62`/`W12X19`; `MF` where it's a moment mark). Give the reader the per-segment length to
69
+ disambiguate chord-vs-rung.
70
+ 4. Build one member per `D` segment using the segment's OWN endpoints as the work-points (so the
71
+ member lands exactly on the drawn line), profile from the map, AISC weight via lookup.
72
+ 5. Replace any earlier free-placed diagonal members in the bay (members whose *real* orientation
73
+ is diagonal) with these exact ones. Cross-check: `D`-segment count ≈ diagonal members — a
74
+ pile of unbound `D` segments means skew is still missed.
75
+ 5. **Bind** each label to the nearest matching segment (within tolerance, orientation penalty on
76
+ mismatch). **Bind `D` labels to `D` segments** (don't let a diagonal label grab a nearby
77
+ orthogonal grid line). Segments that remain unbound are the coverage queue (the human's markup
78
+ worklist) — a pile of unbound `D` segments means skew was missed; go back to step 4.
79
+ 6. **Work-point extend.** Extend each bound segment's drawn-short ends to the centerline
80
+ intersection of connecting members (line–line intersection, capped reach). This closes joints
81
+ and is skew-proof.
82
+ 7. **AISC lookup for weights.** Call the `steel-detailer-us` AWARE agent's `lookup` verb for
83
+ each distinct profile designation → `plf` (pounds per linear foot). Build `weights: {
84
+ profile → plf }`. Unresolved → `rfi: true`, weight omitted. Verify the lookup is live with a
85
+ known section (e.g. W16X26 → 26 lb/ft) before trusting a full run — `~/.aware` rules can
86
+ vanish on agent re-update (see gotcha §6 of the guidance spec).
87
+ 8. **Dedupe coincident members.** Two labels binding the same segment (a W-size + its `MF` mark,
88
+ or overlapping tile reads) emit coincident members that double-count the BOM. Collapse members
89
+ with identical work-points (order-independent, ~3px tol), keeping the most-resolved one. (The
90
+ editor ALSO auto-dedupes coincident members on load as a safety net — but produce a clean
91
+ contract here; don't lean on it.)
92
+ 9. **Cross-sheet sources.** Frame Elevation sheets → moment-frame schedule (resolve `MF` marks),
93
+ level datums (Ground / Second Floor / Roof elevations). Roof framing → per-zone T.O. STEEL
94
+ callouts. See guidance spec §5 for what each sheet gives you.
95
+
96
+ **Hard-won gotchas (from the guidance spec § 6 — do not skip these):**
97
+ - **Text is outlined, not real text.** `get_text()` returns almost nothing. Everything textual
98
+ needs vision.
99
+ - **Pages may be rotated (e.g. 270°).** Work entirely in display space via
100
+ `page.rotation_matrix`. Never mix native/display coords — this is the single biggest source of
101
+ misplaced members.
102
+ - **Subagent reads must return normalized fractions, one pass, no sub-cropping.** Pixel coords
103
+ from a subagent collapse after downscaling.
104
+ - **`MF` / `BF` are marks, not profiles.** No AISC section until resolved from Frame Elevations
105
+ by grid + level. Leave as `rfi` and `mf: true` until auto-resolved or the user picks.
106
+ - **AISC rules can vanish.** Verify `steel-detailer-us` with a known section before a full run
107
+ (`W16X26 → 26 lb/ft`); if every member comes back weightless, restore `rules/aisc-shapes-v15.json`
108
+ into the installed agent dir from the aware-aeco repo. Seen cause: the agent was renamed
109
+ `steel-detailer-aisc → steel-detailer-us`, so an old lookup binary points at a dir that no longer
110
+ exists — make sure you're calling the current `steel-detailer-us` and its rules dir is populated.
111
+ - **Half-grids have no plan coordinate.** Auto-resolve only confident cases; surface the rest in
112
+ the `rfi` panel.
113
+ - **Detail callout detection misreads numbers/sheets.** Verify at high zoom before trusting
114
+ (both the number and the sheet can be wrong).
115
+ - **`(N)` = new, `(E)` = existing (often out of scope).** Read the full set for context before
116
+ deciding.
117
+
118
+ ### 3. Honor `target_confidence`
119
+
120
+ Read the app's `target_confidence` input (default 70). The confidence score is a **deterministic
121
+ function of observable evidence** — not model self-report. Full method in
122
+ `docs/superpowers/specs/2026-06-20-steel-takeoff-confidence-report.md`; the short summary:
123
+
124
+ - **Verified (100%)** — human-confirmed.
125
+ - **High (≥80%)** — fully resolved from drawing evidence, no contradiction.
126
+ - **Medium (50–79%)** — one indirection (mark→schedule) or assumed elevation.
127
+ - **Low (<50%)** — duplicate group, weak bind, half-grid, conflicting sources.
128
+ - **RFI (0%)** — no AISC size resolved.
129
+
130
+ Aggregate confidence = average of per-element scores over the detected set (members with
131
+ resolved profiles + elevations; RFI members drag it down). **Loop**: re-read low-confidence
132
+ regions (re-tile at higher zoom, re-run AISC lookup) until aggregate ≥ `target_confidence` OR
133
+ the returns are clearly diminishing. Be honest if you stop below target — it's a triage guide,
134
+ not a hard gate. The user decides whether to Approve.
135
+
136
+ ### 4. Assemble the `steel.takeoff/v1` contract
137
+
138
+ Emit a JSON object matching the schema in `schemas/steel.takeoff.v1.schema.json`. Top-level
139
+ required fields: `type: "steel.takeoff/v1"` (discriminator), `plans` (array of plan objects).
140
+ Important optional fields:
141
+
142
+ ```jsonc
143
+ {
144
+ "type": "steel.takeoff/v1",
145
+ "source": {
146
+ "name": "<sourceName or basename of the drawing path>",
147
+ "path": "<absolute path you read>",
148
+ "sheet": "<e.g. S-202>",
149
+ "sha256": "<sha256 of the file if available>",
150
+ "read_at": "<ISO 8601 timestamp>"
151
+ },
152
+ "weights": { "W16X26": 26, "HSS6X6X3/8": 27.48 }, // profile → plf; null for unresolved
153
+ "moment_frames": [ … ], // from Frame Elevation sheets
154
+ "detail_bubbles": { "S-504": { "5": [0.32, 0.71] } }, // sheet → { number → [fx,fy] }
155
+ "plans": [
156
+ {
157
+ "sheet": "S-202",
158
+ "title": "Second Floor Framing Plan",
159
+ "clip": [x0, y0, x1, y1], // display-space crop of framing area
160
+ "pt_per_ft": 12.3,
161
+ "default_tos": 198, // decimal inches (16'-6" = 198)
162
+ "raster_b64": "…", // CONFIDENTIAL — machine-local, never committed
163
+ "segments": [ { "id": "seg-1", "a": [x,y], "b": [x,y], "o": "H" } ],
164
+ "labels": [ { "text": "W16X26", "disp": [x,y] } ],
165
+ "details": [ { "text": "5-S504", "disp": [x,y] } ],
166
+ "tos_callouts": [ { "elev_in": 198, "type": "TOS", "disp": [x,y] } ],
167
+ "members": [
168
+ {
169
+ "id": "m1",
170
+ "profile": "W16X26",
171
+ "wp": [[x0,y0],[x1,y1]],
172
+ "angle": "H",
173
+ "role": "beam",
174
+ "rfi": false,
175
+ "mf": false,
176
+ "ends": [
177
+ { "tos": 198, "note": "moment", "tosDef": false, "detail": "5-S504" },
178
+ { "tos": 198, "note": "shear", "tosDef": true, "detail": "" }
179
+ ]
180
+ },
181
+ {
182
+ "id": "c1",
183
+ "profile": "HSS6X6X3/8",
184
+ "wp": [[x,y0],[x,y1]],
185
+ "angle": "V",
186
+ "role": "column",
187
+ "rfi": false,
188
+ "col": { "bos": 0, "tos": 198, "note": "", "detail": "" }
189
+ }
190
+ ]
191
+ }
192
+ ]
193
+ }
194
+ ```
195
+
196
+ Key field rules:
197
+ - All `wp` / `disp` / `a` / `b` coordinates are **display space** (not native PDF coords, not
198
+ millimetres).
199
+ - Elevations are **decimal inches** (canonical); the editor accepts/displays ft-in strings.
200
+ `tosDef: false` means the value came from a drawing callout; `true` means it follows the plan
201
+ default.
202
+ - `rfi: true` means no AISC size resolved — excluded from the BOM. `mf: true` marks a
203
+ moment-frame member (persistent; survives AISC resolution).
204
+ - `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
205
+ **machine-local** and **never committed**.
206
+
207
+ ### 5. Write to the contract store
208
+
209
+ ```
210
+ PUT http://localhost:<port>/api/contract/steel-takeoff
211
+ Content-Type: application/json
212
+
213
+ <contract JSON body>
214
+ ```
215
+
216
+ The server schema-validates the body. If it responds with `400`, read the error, fix the
217
+ offending fields, and re-PUT. You can read back the current contract with
218
+ `GET /api/contract/steel-takeoff`.
219
+
220
+ ### 6. Hand off to the user
221
+
222
+ Tell the user:
223
+ - What you read (e.g. "read 14 beams and 6 columns on sheets S-201/S-202; 3 MF members await
224
+ Frame-Elevation resolution; aggregate confidence 78% — above your 70% target").
225
+ - **Open the editor**: double-click the `read` node (or the takeoff node) in the FloLess
226
+ canvas. The editor renders the overlay, the member list, the RFI panel, and the confidence
227
+ report so they can verify and correct.
228
+ - **Approve & bake lock**: when satisfied, click Approve in the editor. Approve bakes the
229
+ contract into the `read` node's `config.takeoff` and recompiles; the Run gate disarms until
230
+ approved. After approval, the 3D view and BOM nodes render from the baked contract.
231
+ - **Freeze** the `read` node (right-click → Freeze, or `aware app freeze`) once it's approved
232
+ so a future re-read won't overwrite it until they explicitly unfreeze.
233
+ - What's next: the 3D view (`view` node), IFC export, Tekla bake, and formatted BOM are on the
234
+ same canvas and follow from Approve.
235
+
236
+ ### 7. Clear the request
237
+
238
+ If the trigger came from a `rebake` request:
239
+
240
+ ```
241
+ DELETE http://localhost:<port>/api/requests/<id>
242
+ ```
243
+
244
+ ### 8. Confidentiality
245
+
246
+ Never write client, firm, or project identifiers into files, commits, or responses. The
247
+ contract carries the source path in `source.path` and an embedded raster in `raster_b64` — both
248
+ are machine-local (stored under `~/.floless/contracts/steel-takeoff.json` by the server and
249
+ never committed). Clip title blocks from any embedded preview.
250
+
251
+ ---
252
+
253
+ ## Guardrails
254
+
255
+ - **Compose-time only.** The drawing is read HERE, by you, then PUT to the store. Never add a
256
+ runtime node that reads the drawing — `aware app validate` rejects it, and it breaks
257
+ determinism.
258
+ - **Contract, not scene.** This skill produces a `steel.takeoff/v1` contract. The 3D scene
259
+ derives from it at Approve (`contract-to-scene`); do not manually assemble the 3D scene here.
260
+ - **Thin-UI contract.** The browser recorded intent and the drawing path; the brain is you, in
261
+ the terminal. The UI never reads the drawing and never writes the contract directly.
262
+ - **Honest about gaps.** Unbound segments are the human's worklist, not an error. State
263
+ assumptions (plan-default elevations, assumed heights, unresolved MF marks). Never fabricate
264
+ sizes or grid positions the drawing does not show.
265
+ - **Resolve the port** from the running floless.app the same way other floless-app skills do
266
+ (check `~/.floless/port` or the active server port).
@@ -166,6 +166,31 @@ Two rules that bite immediately:
166
166
  whatever the node returns.
167
167
  - **No `System.Net`** (so no `WebUtility.HtmlEncode`) — escape with a manual lambda.
168
168
 
169
+ ## Every node's Description must be plain English (HARD guardrail)
170
+
171
+ The Inspect → **Description** tab is written **for non-coders**: it must always say, in plain
172
+ English, *what the node does* — never raw code, markup, or jargon (CLAUDE.md standing rule).
173
+ When authoring a `.flo`, that is **your** job, node by node:
174
+
175
+ - **Each `exec` node's Description IS its leading `//` comment.** `web/aware.js` `leadingComment()`
176
+ derives both the Description paragraph and the card blurb from the comment block at the top of
177
+ `config.code`. So **start every exec `code:` with a 1–2 sentence plain-English comment** that
178
+ explains what the node does for someone who can't read C# — e.g.
179
+ `// Reads the chosen phase and builds the bill of materials as an HTML table.` A node with no
180
+ leading comment falls back to a generic "read-mode exec node" line — that's the rubbish this
181
+ rule forbids. (You may *mention* a `{{ upstream.result.x }}` knob in the prose, but never let a
182
+ description be *only* a bare `{{ … }}` ref — and remember braces inside `code` are a run-time
183
+ footgun, see the templating note above.)
184
+ - **The workflow itself needs a plain-English top-level `description:`** (a paragraph saying what
185
+ the app is for) — it's required, not decorative.
186
+ - **Don't rely on the display layer to save you.** The Inspect tab summarizes oversized/baked blob
187
+ *inputs* (e.g. a baked `args.html` → `baked HTML · 223 KB`) so they never dump raw markup — but
188
+ that's a safety net for input *values*, not a license to skip the node's own plain-English
189
+ description.
190
+ - **Enforced in CI:** `npm run verify:flo` (`server/build/verify-flo-descriptions.mjs`) fails the
191
+ build if any committed `demos/*/*.flo` ships a node with no plain-English Description. Run it
192
+ before you call a `.flo` done.
193
+
169
194
  ## The build loop (install → validate → compile → run)
170
195
 
171
196
  Resolve the `aware` CLI the same way the adapter does: prefer the npm global
@@ -377,6 +402,9 @@ past a few hard facts, split it into a `references/` file and link it.
377
402
 
378
403
  ## Guardrails (do not drift)
379
404
 
405
+ - **Every node's Description is plain English** — each exec node opens with a 1–2 sentence
406
+ plain-English `//` comment (its Description), and the app has a plain-English `description:`.
407
+ Enforced by `npm run verify:flo` in CI. See "Every node's Description must be plain English".
380
408
  - Determinism is AWARE's: `<app>.lock` is the approved artifact; Run is gated on a fresh
381
409
  source-hash. Never let the UI run on drift.
382
410
  - Any change under `web/` requires a Playwright pass (visual + interaction), not just a check of
package/dist/web/aware.js CHANGED
@@ -1073,12 +1073,13 @@
1073
1073
  });
1074
1074
  if (!res) return;
1075
1075
  const dataUrl = res[name];
1076
+ const sourceName = res[name + '__filename'] || undefined;
1076
1077
  if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
1077
1078
  try {
1078
1079
  const r = await fetch('/api/rebake', {
1079
1080
  method: 'POST',
1080
1081
  headers: { 'content-type': 'application/json' },
1081
- body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
1082
+ body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: [{ dataUrl }] }),
1082
1083
  });
1083
1084
  const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
1084
1085
  if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
@@ -1417,6 +1418,9 @@
1417
1418
  Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1418
1419
  Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
1419
1420
  );
1421
+ // Focus the iframe once it loads so its keyboard shortcuts (Ctrl+D, etc.) work without
1422
+ // the user first having to click inside it.
1423
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1420
1424
  $contractEditorFrame.src = r.editorUrl(appId);
1421
1425
  $contractEditor.hidden = false;
1422
1426
  return true;
@@ -1956,7 +1960,11 @@
1956
1960
  const out = {};
1957
1961
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
1958
1962
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
1959
- fileStates.forEach((entry, name) => { out[name] = entry.st.mode === 'empty' ? '' : entry.st.value; });
1963
+ fileStates.forEach((entry, name) => {
1964
+ out[name] = entry.st.mode === 'empty' ? '' : entry.st.value;
1965
+ // expose filename alongside so callers can capture provenance (e.g. rebake → sourceName)
1966
+ out[name + '__filename'] = entry.st.name || '';
1967
+ });
1960
1968
  return out;
1961
1969
  };
1962
1970
  const done = (result) => {
@@ -2,6 +2,13 @@
2
2
  <title>Steel takeoff — editor</title>
3
3
  <style>
4
4
  :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
5
+ /* Theme EVERY scrollbar — no native white default may leak (applies to the canvas, panels, and every modal list). */
6
+ *{scrollbar-width:thin;scrollbar-color:#475569 transparent}
7
+ *::-webkit-scrollbar{width:10px;height:10px}
8
+ *::-webkit-scrollbar-track{background:transparent}
9
+ *::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
10
+ *::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
11
+ *::-webkit-scrollbar-corner{background:transparent}
5
12
  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
6
13
  header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
7
14
  header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
@@ -134,7 +141,8 @@
134
141
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
135
142
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
136
143
  <span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
137
- <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span><span style="flex:1"></span>
144
+ <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
145
+ <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
138
146
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
139
147
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
140
148
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
@@ -207,6 +215,9 @@
207
215
  <script>
208
216
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
209
217
  let C, PAL, WT;
218
+ // Fallback palette (colorFor indexes this array by profile order) when a contract ships no `palette`
219
+ // — e.g. a contract shared/committed with raster_b64 + palette stripped for confidentiality.
220
+ const DEFAULT_PAL=['#60a5fa','#94a3b8','#f59e0b','#34d399','#f472b6','#a78bfa','#fb7185','#22d3ee','#facc15','#4ade80'];
210
221
  let lastLocalPut = 0; // timestamp of our own PUT — used to suppress the SSE echo
211
222
  function showEmpty(icon, headline, bodyText, appIdText) {
212
223
  const wrap = document.createElement('div');
@@ -231,7 +242,7 @@ async function boot() {
231
242
  return;
232
243
  }
233
244
  C = await res.json();
234
- PAL = C.palette; WT = C.weights;
245
+ PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
235
246
  C.custom_details = C.custom_details || {};
236
247
  C.profile_colors = C.profile_colors || {};
237
248
  main();
@@ -421,6 +432,12 @@ function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;retur
421
432
  // --- zoom + pan ---
422
433
  const ZMIN=0.1, ZMAX=4;
423
434
  let zoom=1; const stage=document.getElementById('stage');
435
+ // The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
436
+ // iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
437
+ // Ctrl+D would hit the browser (bookmark) instead. Make the canvas focusable + grab focus on any
438
+ // pointerdown so shortcuts work after the user clicks into the drawing.
439
+ stage.tabIndex=-1; stage.style.outline='none';
440
+ stage.addEventListener('pointerdown',()=>{try{stage.focus({preventScroll:true});}catch(_){}}, true);
424
441
  function applyZoom(z,ax,ay){z=Math.max(ZMIN,Math.min(ZMAX,z));
425
442
  const pt=(ax!=null)?toSvg({clientX:ax,clientY:ay}):null;
426
443
  zoom=z;svg.setAttribute('width',EXTX*zoom);svg.setAttribute('height',EXTY*zoom);
@@ -437,7 +454,7 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
437
454
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
438
455
  function render(){
439
456
  if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} // geo edit needs exactly one member
440
- let s=`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`;
457
+ let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
441
458
  for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
442
459
  for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
443
460
  s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
@@ -574,17 +591,28 @@ function rectHit(p0,p1,r){
574
591
  for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
575
592
  return false;}
576
593
  // --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections); Alt = off ---
577
- const SNAP_PX=9; let snapPts=[];
594
+ const SNAP_PX=9; let snapPts=[], snapSegs=[];
578
595
  function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
579
- snapPts=[];for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);}for(const s of P.segments){snapPts.push(s.a,s.b);}}
596
+ snapPts=[];snapSegs=[];
597
+ for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);}
598
+ for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);}}
599
+ // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
600
+ function nearestOnLine(x,y,tol){let bp=null,bd=tol;
601
+ for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
602
+ return bp?{x:bp[0],y:bp[1],d:bd}:null;}
580
603
  function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
581
604
  for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
582
- if(bp)return {x:bp[0],y:bp[1],hit:true};
583
- let sx=x,sy=y,bx=tol,by=tol;
605
+ if(bp)return {x:bp[0],y:bp[1],hit:true}; // 1) snap to an endpoint/grid intersection
606
+ const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true}; // 2) else snap ONTO a line (not only its endpoints)
607
+ let sx=x,sy=y,bx=tol,by=tol; // 3) else axis-align to a nearby point
584
608
  for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
585
609
  return {x:sx,y:sy,hit:false};}
586
610
  function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
587
611
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
612
+ // themed transient toast (baseline tokens — never a native alert)
613
+ function toast(msg){let t=document.getElementById('toast');
614
+ if(!t){t=document.createElement('div');t.id='toast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s;pointer-events:none';document.body.appendChild(t);}
615
+ t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
588
616
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
589
617
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
590
618
  if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
@@ -631,9 +659,10 @@ svg.addEventListener('pointermove',e=>{
631
659
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
632
660
  if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
633
661
  if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
634
- else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so its nearest endpoint lands on a snap target
662
+ else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
635
663
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
636
- for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}}
664
+ for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
665
+ const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny};}} // also snap onto other lines
637
666
  if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1]);}else snapClear();}
638
667
  else snapClear();
639
668
  for(const it of drag.items){const m=byId(it.id);if(!m)continue;
@@ -956,9 +985,27 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
956
985
  e.preventDefault();e.stopPropagation();opts.forEach((o,i)=>o.classList.toggle('active',i===comboIdx));if(comboIdx>=0)opts[comboIdx].scrollIntoView({block:'nearest'});},true);
957
986
  // --- multi-plan: switch between all steel plan views ---
958
987
  function setPlan(i){C.active=i;P=C.plans[i];
959
- X0=P.clip[0];Y0=P.clip[1];X1=P.clip[2];Y1=P.clip[3];FT=P.pt_per_ft;RB64=P.raster_b64;EXTX=X1-X0;EXTY=Y1-Y0;
988
+ if(!Array.isArray(P.members))P.members=[];
989
+ if(!Array.isArray(P.segments))P.segments=[];
990
+ // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
991
+ // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
992
+ // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
993
+ let clip=P.clip;
994
+ if(!Array.isArray(clip)||clip.length<4){
995
+ const xs=[],ys=[];for(const m of P.members)for(const pt of (m.wp||[])){xs.push(pt[0]);ys.push(pt[1]);}
996
+ if(xs.length){const pad=Math.max(200,(Math.max(...xs)-Math.min(...xs))*0.1);
997
+ clip=[Math.min(...xs)-pad,Math.min(...ys)-pad,Math.max(...xs)+pad,Math.max(...ys)+pad];}
998
+ else clip=[0,0,1000,1000];}
999
+ X0=clip[0];Y0=clip[1];X1=clip[2];Y1=clip[3];FT=P.pt_per_ft||12;RB64=P.raster_b64||'';EXTX=X1-X0;EXTY=Y1-Y0;
960
1000
  svg.setAttribute('viewBox',`${X0} ${Y0} ${X1-X0} ${Y1-Y0}`);
961
1001
  P.members.forEach(ensureMeta);
1002
+ // Auto-dedupe coincident members on first load of each plan — the read can bind one segment to
1003
+ // two labels (a W-size + its MF mark) or overlap tile reads, producing stacked duplicates that
1004
+ // double-count the BOM. Collapse them automatically (keep the most-resolved copy), undoable, once
1005
+ // per plan. The "Duplicates" button stays for any made while editing.
1006
+ if(!P.deduped){P.deduped=true;const red=new Set(redundantDups());
1007
+ if(red.size){(P.undo=P.undo||[]).push(JSON.stringify(P.members));P.members=P.members.filter(m=>!red.has(m.id));
1008
+ scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
962
1009
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
963
1010
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
964
1011
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
@@ -974,6 +1021,18 @@ document.getElementById('revertB').onclick=()=>{if(confirm('Discard ALL saved ed
974
1021
  const _restored=restoreSaved(); // bring back this browser's saved edits before first paint
975
1022
  setPlan(C.active==null?0:C.active);
976
1023
  setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
1024
+ try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
1025
+ // --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
1026
+ (function(){
1027
+ const src=C.source;if(!src||typeof src!=='object')return;
1028
+ const parts=[];
1029
+ if(src.name)parts.push('read from '+src.name);
1030
+ if(src.sheet)parts.push(src.sheet);
1031
+ if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
1032
+ if(!parts.length)return;
1033
+ const el=document.getElementById('srcStat');if(!el)return;
1034
+ el.textContent=parts.join(' · ');el.style.display='';
1035
+ })();
977
1036
  // --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
978
1037
  // The server records a tweak-contract request; the terminal AI picks it up async and PUTs
979
1038
  // back a revised contract. This is NOT a chat — the status message says so honestly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.31.3",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "build": "node build/bundle.mjs",
25
25
  "build:exe": "node build/bundle.mjs && node build/make-sea.mjs",
26
26
  "verify:skills": "node build/verify-shipped-skills.mjs",
27
+ "verify:flo": "node build/verify-flo-descriptions.mjs",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "prepack": "npm run build"
29
30
  },