@floless/app 0.60.0 → 0.61.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.60.0" : void 0,
53025
+ define: true ? "0.61.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.60.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.61.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -91,7 +91,7 @@
91
91
  "type": "object",
92
92
  "required": ["id", "kind"],
93
93
  "additionalProperties": true,
94
- "description": "One drawing element. STABLE, sheet-unique `id` (assigned by the post-processor if the extractor omitted it) so it can be selected, exported, and targeted for modification.",
94
+ "description": "One drawing element. STABLE, sheet-unique `id`, REQUIRED on write (the store validates before the post-processor runs; the post-processor only repairs id collisions on read) so every element can be selected, exported, and targeted for modification.",
95
95
  "properties": {
96
96
  "id": { "type": "string", "description": "Stable, sheet-unique element id (e.g. 'e12')." },
97
97
  "kind": { "enum": ["line", "arc", "polyline", "spline", "text"], "description": "Geometry class (broader than steel's line/text)." },
@@ -1,26 +1,33 @@
1
1
  ---
2
2
  name: floless-app-vectorize
3
- description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my drawing into clean CAD linework", "read this detail into vectors", "trace this sheet". Teaches the host AI to run ONE deterministic PyMuPDF pass at COMPOSE time (no LLM, no API key) over a vector PDF, emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user.
3
+ description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my sketch into clean CAD linework", "read this detail into vectors", "trace this photo". Teaches the host AI to read a drawing at COMPOSE time — a vector PDF via ONE deterministic PyMuPDF pass (no LLM), a scan/photo/hand sketch via AWARE's fenced vision.extract (schema-bound, cached) — emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user.
4
4
  metadata:
5
- version: 0.1.0
5
+ version: 0.3.0
6
6
  ---
7
7
 
8
8
  # Vectorize — read a drawing into a drawing.vector/v1 contract
9
9
 
10
10
  ## What this is
11
11
 
12
- `vectorize` is a FloLess app that turns a drawing (PDF today; scans/photos are a later
13
- slice) into clean, editable **2D vector linework** a `drawing.vector/v1` contract
12
+ `vectorize` is a FloLess app that turns a drawing — a vector PDF, a scan, a photo, a hand
13
+ sketch into clean, editable **2D vector linework**: a `drawing.vector/v1` contract
14
14
  (vocabulary-free; every element has a stable id) rendered in the 2D vector editor, where the
15
- user pans/zooms, toggles Lines/Curves/Text and layers, inspects weak traces, and exports SVG.
15
+ user pans/zooms, toggles Lines/Curves/Text and layers, reviews weak traces, and exports SVG.
16
16
 
17
- The drawing is read **at compose time** with one deterministic PyMuPDF pass. There is **no
18
- model in the run path and no API key** — and for a vector PDF no vision either: the geometry
19
- is exact, extracted, never guessed. The app ships with a tiny hard-coded example; this skill
20
- is how the user's OWN drawing replaces it.
17
+ The drawing is read **at compose time**, by one of two paths:
21
18
 
22
- **Later slices (mention as "next", do not attempt here):** raster/photo input via AWARE
23
- `vision.extract`, editor Save/edit, multi-view 3D reconstruction, DXF export.
19
+ - **Vector PDF deterministic.** One PyMuPDF pass; the geometry is exact, extracted, never
20
+ guessed. No model, no API key.
21
+ - **Raster (scan/photo/sketch) → AWARE `vision.extract`.** The substrate's fenced extraction
22
+ carve-out: a FIXED schema + prompt + pinned model, content-hash cached (same input → same
23
+ JSON, no repeat model call). Coordinates come from the model, flagged with per-element
24
+ confidence the editor surfaces for review — never from you eyeballing pixels.
25
+
26
+ There is **no model in the run path** either way. The app ships with a tiny hard-coded
27
+ example; this skill is how the user's OWN drawing replaces it.
28
+
29
+ **Later slices (mention as "next", do not attempt here):** multi-view 3D reconstruction,
30
+ DXF export.
24
31
 
25
32
  > **Pasted a request?** If the user pastes a message beginning with a `[floless-request
26
33
  > type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
@@ -39,14 +46,15 @@ prose, skip the lookup and proceed from that path. Resolve the port from the run
39
46
  floless.app (check `~/.floless/port` or the active server port), as the other floless-app
40
47
  skills do.
41
48
 
42
- ### 2. Gate is it a VECTOR PDF?
49
+ ### 2. Pick the path vector or raster?
43
50
 
44
- This skill's deterministic path reads **vector** PDFs only. If `page.get_drawings()` returns
45
- nothing on every page (a scan, a photo, a hand sketch), **stop and say so honestly** the
46
- raster/vision path is a later slice and is not shipped. Never trace pixels by eye into
47
- made-up coordinates; fabricated geometry is worse than no geometry.
51
+ Probe the file: a PDF whose pages return `page.get_drawings()` paths is a **vector PDF** →
52
+ step 3a (deterministic, always preferred). An image file, or a PDF with no vector paths (a
53
+ scan, a photo, a hand sketch) step 3b (vision). **Never trace pixels by eye into made-up
54
+ coordinates** fabricated geometry is worse than no geometry; the raster path exists so the
55
+ extraction is fenced, cached, and confidence-flagged instead.
48
56
 
49
- ### 3. Extract — one deterministic PyMuPDF pass
57
+ ### 3a. Vector path — one deterministic PyMuPDF pass
50
58
 
51
59
  Run the bundled extractor (it lives beside this file):
52
60
 
@@ -81,6 +89,36 @@ above is the contract. Hard schema requirements the server enforces on PUT: `typ
81
89
  dedupe, snap, or derive layers yourself — the server post-processes every read of this
82
90
  contract type.
83
91
 
92
+ ### 3b. Raster path — AWARE `vision.extract` (the fenced exception)
93
+
94
+ For each raster image (or rasterized PDF page exported to an image):
95
+
96
+ 1. Copy `references/vision-inputs.template.json` (beside this file) to a scratch file and set
97
+ **only** `"file"` to the image's absolute path. The `prompt`, `schema`, and pinned `model`
98
+ are FIXED — together with the file bytes they form the extraction's content-hash cache key
99
+ and its fence; do not tweak them per run.
100
+ 2. Invoke the substrate:
101
+
102
+ ```
103
+ aware agent invoke vision extract --inputs @<scratch>/vision-inputs.json --json > <scratch>/vision-out.json
104
+ ```
105
+
106
+ A cache miss shells out to the local authenticated `claude`/`codex` CLI (no API key) and can
107
+ take a minute; a repeat of the same input replays from cache instantly. If neither CLI nor a
108
+ `vision-model` credential is available, stop and tell the user what to set up.
109
+ 3. Convert to the contract (the model traces in a normalized 0..1000 frame; this maps it onto
110
+ the image's real pixel size and carries per-element confidence):
111
+
112
+ ```
113
+ python <skill dir>/scripts/vision_to_contract.py <scratch>/vision-out.json <image> --out contract.json
114
+ ```
115
+
116
+ For a multi-image set, run steps 1–3 per image and append with
117
+ `--merge-into contract.json` instead of `--out`.
118
+ 4. Sanity-check the result before PUT: element count > 0, and the traced text you can see in
119
+ the vision output matches what the drawing actually says. A wildly wrong trace usually means
120
+ the image is too low-contrast — say so rather than shipping garbage.
121
+
84
122
  ### 4. Write to the contract store
85
123
 
86
124
  ```
@@ -101,10 +139,14 @@ Tell the user:
101
139
 
102
140
  - **What was read** — e.g. "2 sheets, 214 lines, 12 curves, 96 text spans from `detail.pdf`".
103
141
  - **Open the editor**: double-click the `read` node on the Vectorize canvas. It renders their
104
- drawing — pan/zoom + Fit, Lines/Curves/Text toggles, per-layer toggles, weak-trace isolation
105
- (deterministic reads have none), and **Export SVG**.
106
- - The editor is **view-only for now** no Save/Approve path yet (a later slice); corrections
107
- mean re-running this read with a better source.
142
+ drawing — pan/zoom + Fit, Lines/Curves/Text toggles, per-layer toggles, weak-trace isolation,
143
+ and **Export SVG**. Deterministic reads have no weak traces; after a **vision** read, point
144
+ the user at the **Confidence** paneleach trace the model was unsure about (confidence
145
+ < 0.5) is listed there with **Accept** (mark correct) and **Delete** (remove, with Undo), and
146
+ a "Reviewed N/M" counter tracks progress. Edits auto-save to the contract store.
147
+ - **Approve & bake lock**: when satisfied, the user clicks Approve in the editor header — it
148
+ bakes the (edited) contract into the `read` node's `config.takeoff` and recompiles, arming
149
+ the Run gate. Never auto-approve on their behalf.
108
150
 
109
151
  ### 6. Clear the request
110
152
 
@@ -119,8 +161,11 @@ DELETE http://localhost:<port>/api/requests/<id>
119
161
  - **Compose-time only.** The drawing is read HERE, by the terminal AI, then PUT to the store.
120
162
  Never add a runtime node that reads the drawing — `aware app validate` rejects it, and it
121
163
  breaks determinism.
122
- - **Deterministic or nothing.** Vector geometry is extracted exactly; a raster with no vector
123
- paths is out of scope until the vision slice ships. No eyeballed coordinates, ever.
164
+ - **Deterministic first, fenced second, eyeballs never.** Vector geometry is extracted
165
+ exactly; raster interpretation goes ONLY through `vision.extract` (fixed schema/prompt/model,
166
+ cached, confidence-flagged). Never hand-write coordinates you read off pixels yourself, and
167
+ never swap the vision path for your own vision — the fence is what makes the extraction
168
+ reproducible.
124
169
  - **Thin-UI contract.** The browser recorded intent and the file path; the brain is the
125
170
  terminal AI. The UI never reads the drawing and never writes the contract itself.
126
171
  - **Stay in scope.** Producing and handing off the contract is the whole job — do not wire
@@ -0,0 +1,40 @@
1
+ {
2
+ "file": "<FILL IN: absolute path to the raster image/PDF>",
3
+ "model": "claude-sonnet-5",
4
+ "prompt": "Trace every visible stroke and label in this drawing into vector geometry. Use a coordinate frame where x runs 0..1000 left to right and y runs 0..1000 top to bottom over the full image. Emit each straight stroke as kind 'line' (exactly two points) or a connected run of straight strokes as 'polyline' (a vertex chain); follow smooth curves with a 'polyline' of enough vertices to track the curve (at most 20). Emit every piece of text as kind 'text' with the text content and its bounding box [x0,y0,x1,y1]. Mark a stroke dashed:true only if it is visibly drawn dashed. Do NOT invent geometry that is not visibly drawn; skip shading, hatching texture, smudges and paper artifacts. For every element report confidence between 0 and 1 — report 0.5 or lower whenever you are unsure the stroke exists, its endpoints are ambiguous, or the text is hard to read.",
5
+ "schema": {
6
+ "type": "object",
7
+ "required": ["elements"],
8
+ "properties": {
9
+ "elements": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "required": ["kind", "confidence"],
14
+ "allOf": [
15
+ { "if": { "properties": { "kind": { "enum": ["line", "polyline"] } } }, "then": { "required": ["pts"] } },
16
+ { "if": { "properties": { "kind": { "const": "text" } } }, "then": { "required": ["text", "bbox"] } }
17
+ ],
18
+ "properties": {
19
+ "kind": { "enum": ["line", "polyline", "text"] },
20
+ "pts": {
21
+ "type": "array",
22
+ "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
23
+ "description": "Vertices in the 0..1000 frame (line/polyline)"
24
+ },
25
+ "text": { "type": "string" },
26
+ "bbox": {
27
+ "type": "array",
28
+ "items": { "type": "number" },
29
+ "minItems": 4,
30
+ "maxItems": 4,
31
+ "description": "[x0,y0,x1,y1] in the 0..1000 frame (text)"
32
+ },
33
+ "dashed": { "type": "boolean" },
34
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """vision.extract result -> drawing.vector/v1 contract (the Slice-2 raster path).
3
+
4
+ Takes the JSON that `aware agent invoke vision extract --json` printed (the invoke envelope
5
+ or the bare `{result: {elements: [...]}}`) plus the image it was extracted from, and emits a
6
+ raw drawing.vector/v1 contract ready to PUT to /api/contract/vectorize. The model traces in a
7
+ normalized 0..1000 frame (see references/vision-inputs.template.json); this script maps that
8
+ frame onto the image's real pixel size, carries each element's model-reported confidence
9
+ (the 2D editor flags traces below 0.5 for review), and assigns the ids the PUT schema requires.
10
+
11
+ Usage:
12
+
13
+ python vision_to_contract.py vision-out.json sketch.png --out contract.json
14
+ python vision_to_contract.py vision-out2.json page2.png --merge-into contract.json
15
+
16
+ --merge-into appends the extraction as the next sheet of an existing contract (multi-image sets).
17
+ Requires PyMuPDF (used only to read the image/PDF pixel size).
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+
27
+ import fitz # PyMuPDF
28
+
29
+
30
+ def unwrap(payload):
31
+ """Accept the aware invoke envelope ({ok,data:{result,model}}) or the bare output."""
32
+ if payload.get("ok") is False:
33
+ sys.exit(f"vision.extract failed: {payload.get('error') or 'unknown error'}")
34
+ if isinstance(payload.get("data"), dict):
35
+ payload = payload["data"]
36
+ result = payload.get("result", payload)
37
+ model = payload.get("model", "?")
38
+ if not isinstance(result, dict) or "elements" not in result:
39
+ sys.exit("vision output has no result.elements — did the extraction fail?")
40
+ return result["elements"], model
41
+
42
+
43
+ def to_sheet(elements, image_path):
44
+ # Images only: a multi-page PDF has no single 0..1000 frame, so the caller rasterizes each
45
+ # PDF page to its own image first (SKILL.md step 3b) and runs one extraction per image.
46
+ if image_path.lower().endswith(".pdf"):
47
+ sys.exit("pass the rasterized image, not the PDF — export each PDF page to a PNG first")
48
+ # True pixel dimensions (a Pixmap ignores DPI metadata, which would skew the frame).
49
+ pix = fitz.Pixmap(image_path)
50
+ w, h = float(pix.width), float(pix.height)
51
+ sx, sy = w / 1000.0, h / 1000.0
52
+
53
+ out = []
54
+ for el in elements:
55
+ kind = el.get("kind")
56
+ conf = el.get("confidence")
57
+ if kind == "text":
58
+ bbox = el.get("bbox")
59
+ if not el.get("text") or not bbox or len(bbox) != 4:
60
+ continue
61
+ x0, y0, x1, y1 = (v * s for v, s in zip(bbox, (sx, sy, sx, sy)))
62
+ e = {
63
+ "kind": "text",
64
+ "text": el["text"],
65
+ "bbox": [round(v, 1) for v in (x0, y0, x1, y1)],
66
+ "origin": [round(x0, 1), round(y1, 1)], # baseline ~ bbox bottom-left
67
+ "size": round((y1 - y0) * 0.85, 1), # approx font size from box height
68
+ }
69
+ elif kind in ("line", "polyline"):
70
+ pts = el.get("pts") or []
71
+ if len(pts) < 2:
72
+ continue
73
+ e = {
74
+ "kind": "line" if len(pts) == 2 else "polyline",
75
+ "pts": [[round(p[0] * sx, 1), round(p[1] * sy, 1)] for p in pts],
76
+ }
77
+ if el.get("dashed"):
78
+ e["dashed"] = True
79
+ else:
80
+ continue
81
+ # Schema: confidence is a number when present; ABSENT means trusted. A model that omits
82
+ # it must not become `null` — one null fails the whole PUT (server validates every write).
83
+ if isinstance(conf, (int, float)):
84
+ e["confidence"] = max(0.0, min(1.0, conf))
85
+ out.append(e)
86
+ for i, el in enumerate(out):
87
+ el["id"] = f"e{i + 1}"
88
+ return {"page": {"w": round(w, 2), "h": round(h, 2)}, "elements": out}
89
+
90
+
91
+ def main():
92
+ for stream in (sys.stdout, sys.stderr):
93
+ try:
94
+ stream.reconfigure(encoding="utf-8")
95
+ except AttributeError:
96
+ pass
97
+ ap = argparse.ArgumentParser(description=__doc__)
98
+ ap.add_argument("vision_json", help="output of `aware agent invoke vision extract --json`")
99
+ ap.add_argument("image", help="the raster image the extraction ran on (rasterize PDF pages first)")
100
+ ap.add_argument("--out", help="write a NEW one-sheet contract here")
101
+ ap.add_argument("--merge-into", help="append as the next sheet of this existing contract")
102
+ args = ap.parse_args()
103
+ if bool(args.out) == bool(args.merge_into):
104
+ sys.exit("pass exactly one of --out / --merge-into")
105
+
106
+ with open(args.vision_json, encoding="utf-8") as f:
107
+ elements, model = unwrap(json.load(f))
108
+ sheet = to_sheet(elements, args.image)
109
+ stem = os.path.splitext(os.path.basename(args.image))[0]
110
+
111
+ if args.merge_into:
112
+ with open(args.merge_into, encoding="utf-8") as f:
113
+ contract = json.load(f)
114
+ if not isinstance(contract.get("sheets"), list):
115
+ sys.exit(f"{args.merge_into} is not a drawing.vector contract (no sheets[])")
116
+ used = {s.get("id") for s in contract["sheets"]}
117
+ n = len(contract["sheets"]) + 1
118
+ while f"s{n}" in used:
119
+ n += 1
120
+ sheet["id"] = f"s{n}"
121
+ sheet["label"] = stem
122
+ contract["sheets"].append(sheet)
123
+ path = args.merge_into
124
+ else:
125
+ sheet["id"] = "s1"
126
+ sheet["label"] = stem
127
+ with open(args.image, "rb") as f:
128
+ digest = hashlib.sha256(f.read()).hexdigest()
129
+ contract = {
130
+ "type": "drawing.vector/v1",
131
+ "units": "px", # no transform emitted -> the image's pixel frame is world 1:1
132
+ "source": {
133
+ "name": os.path.basename(args.image),
134
+ "path": os.path.abspath(args.image),
135
+ "kind": "image",
136
+ "sha256": digest,
137
+ "read_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
138
+ "extractor": f"vision.extract@{model}",
139
+ },
140
+ "sheets": [sheet],
141
+ }
142
+ path = args.out
143
+
144
+ with open(path, "w", encoding="utf-8") as f:
145
+ json.dump(contract, f, ensure_ascii=False)
146
+ weak = sum(1 for e in sheet["elements"] if (e.get("confidence") or 1) < 0.5)
147
+ print(f"{path}: sheet {sheet['id']} ({stem}), {len(sheet['elements'])} element(s), {weak} weak", file=sys.stderr)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -1,18 +1,22 @@
1
1
  app: vectorize
2
- version: 0.1.0
2
+ version: 0.2.0
3
3
  display-name: Vectorize
4
4
  publisher: floless
5
5
  description: |
6
6
  Turn any drawing — a sketch, a PDF, a photo — into clean, editable 2D vectors. Your terminal AI
7
- reads the drawing ONCE at compose time (a deterministic PyMuPDF pass no LLM) and bakes it into a
8
- drawing.vector contract; double-click the node to open the 2D vector editor, where you pan and zoom,
9
- toggle what's shown, flag weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸"
10
- (or ask your terminal AI) to vectorize your own; the deterministic run just re-renders a short summary
11
- of what was read. Ships with a tiny example so it works out of the box.
7
+ reads the drawing ONCE at compose time and bakes it into a drawing.vector contract: a vector PDF
8
+ is read deterministically (exact geometry, no AI guessing), while a photo, scan or hand sketch is
9
+ traced by a fenced vision extraction that marks every stroke it was unsure about for your review.
10
+ Double-click the node to open the 2D vector editor, where you pan and zoom, toggle what's shown,
11
+ review weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸" (or ask your
12
+ terminal AI) to vectorize your own; the deterministic run just re-renders a short summary of what
13
+ was read. Ships with a tiny example so it works out of the box.
12
14
  exposes-as-agent: false
13
15
  # Workflow changelog (FloLess-published). Newest first; the app shows entries newer than the installed
14
16
  # version when offering an update. Plain-English — it surfaces to the user.
15
17
  changelog:
18
+ - version: 0.2.0
19
+ summary: Photos, scans and hand sketches can now be vectorized too — unsure strokes are flagged for your review
16
20
  - version: 0.1.0
17
21
  summary: Vectorize a drawing into clean 2D linework you can edit and export
18
22
  inputs:
@@ -28,13 +32,17 @@ layout: linear
28
32
  nodes:
29
33
  # ── node 1: read — vectorize the drawing into clean 2D linework ───────────────
30
34
  # This is where your drawing becomes editable vectors. Your terminal AI reads the
31
- # attached drawing once (a deterministic PyMuPDF pass no LLM) and bakes every line,
32
- # curve and label into the contract. Double-click this node to open the 2D vector
33
- # editor: pan and zoom the drawing, toggle Lines / Curves / Text, flag any weak traces,
34
- # and Export SVG. `contract` tells FloLess which editor to open; `takeoff` is the single
35
- # source the editor, Approve and the run all read. It ships with a tiny EXAMPLE so it
36
- # renders out of the box "Re-read & re-bake ▸" replaces it with your own drawing. The
37
- # deterministic run re-renders the short summary below.
35
+ # attached drawing once at compose time: a vector PDF is read deterministically (exact
36
+ # geometry, no AI guessing), while a photo, scan or hand sketch is traced by a fenced
37
+ # vision extraction that flags every stroke it was unsure about. Every line, curve and
38
+ # label is baked into the contract. Double-click this node to open the 2D vector
39
+ # editor: pan and zoom the drawing, toggle Lines / Curves / Text, review each flagged
40
+ # trace (accept it as correct or delete it edits save automatically), Export SVG,
41
+ # and Approve to freeze the reviewed drawing into the runnable lock. `contract` tells
42
+ # FloLess which editor to open; `takeoff` is the single source the editor, Approve and
43
+ # the run all read. It ships with a tiny EXAMPLE so it renders out of the box —
44
+ # "Re-read & re-bake ▸" replaces it with your own drawing. The deterministic run
45
+ # re-renders the short summary below.
38
46
  - id: read
39
47
  agent: html-report
40
48
  command: render
package/dist/web/aware.js CHANGED
@@ -1360,14 +1360,18 @@
1360
1360
  // openContractEditor() returns false and the click would silently do nothing.
1361
1361
  if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
1362
1362
  addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
1363
- addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1364
- // Export/write group visually separated from the look-and-read actions above.
1365
- addNodeActionDivider(card);
1366
- addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1367
- addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1368
- addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1369
- const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1370
- teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1363
+ // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1364
+ // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
1365
+ if (window.CONTRACT_RENDERERS[contractType].scene) {
1366
+ addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1367
+ // Export/write group visually separated from the look-and-read actions above.
1368
+ addNodeActionDivider(card);
1369
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1370
+ addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1371
+ addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1372
+ const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1373
+ teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1374
+ }
1371
1375
  card.dataset.tip = 'Double-click to open the contract editor';
1372
1376
  }
1373
1377
  // Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
@@ -9,15 +9,16 @@
9
9
  * ========================================================================== */
10
10
 
11
11
  // contract type → descriptor for the editor surface.
12
+ // `scene: true` marks contracts that derive a 3D scene (contract-to-scene) — only those get the
13
+ // node-card View 3D / Export IFC / Export BOM / Send to Tekla chain; other contract types would
14
+ // 4xx or produce nonsense on those endpoints, so the shell hides the whole group.
12
15
  window.CONTRACT_RENDERERS = {
13
16
  'steel.takeoff/v1': {
14
17
  editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
18
+ scene: true,
15
19
  },
16
20
  'drawing.vector/v1': {
17
21
  editorUrl: (appId) => '/vector-editor.html?app=' + encodeURIComponent(appId),
18
- // View-only for now: the vector editor renders + exports SVG but has no Save/edit path yet (the
19
- // edit/re-bake flow is a later slice), so the shell hides its Approve button (see openContractEditor).
20
- viewOnly: true,
21
22
  },
22
23
  };
23
24
 
@@ -47,6 +47,27 @@
47
47
  #zoombar #zpct{min-width:42px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
48
48
  #zoombar .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
49
49
  #zoombar .ghost:hover{color:var(--text);border-color:var(--brand)}
50
+ /* Auto-save chip — same states/colors as steel-editor's #saveStat. */
51
+ #saveStat{color:var(--mut);font-size:12px}
52
+ #saveStat.dirty{color:#fbbf24} #saveStat.err{color:#f87171}
53
+ /* Weak-trace review list (inner scroll box — inherits the themed scrollbar rules above). */
54
+ #weakList{max-height:280px;overflow:auto;display:flex;flex-direction:column;gap:2px;margin-top:4px}
55
+ .wrow{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px}
56
+ .wrow:hover{background:var(--line)}
57
+ .wrow .lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
58
+ .wrow .pct{color:#f59e0b;font-variant-numeric:tabular-nums;font-size:11px}
59
+ .wrow button{height:22px;padding:0 8px;font-size:11px;border-radius:5px}
60
+ .wrow .ghost{background:transparent;border:1px solid var(--line);color:var(--mut)}
61
+ .wrow .ghost:hover{color:var(--text);border-color:var(--brand);background:transparent}
62
+ .wrow .danger{background:#7f1d1d;border-color:#991b1b;color:#fecaca}
63
+ .wrow .danger:hover{background:#991b1b}
64
+ .wrow.flash{background:var(--line)}
65
+ #svg .locfl{stroke:var(--brand)!important} #svg text.locfl{fill:var(--brand)!important}
66
+ /* Single-level Undo toast (styled modal pattern — never a native dialog). */
67
+ #toast{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 12px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:6;color:var(--text)}
68
+ #toast.show{display:flex}
69
+ #toast .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
70
+ #toast .ghost:hover{color:var(--text);border-color:var(--brand)}
50
71
  </style>
51
72
  </head>
52
73
  <body>
@@ -55,6 +76,7 @@
55
76
  <span class="stat" id="title">—</span>
56
77
  <span class="spacer"></span>
57
78
  <span class="stat"><b id="nEl">0</b> elements · <b id="nTx">0</b> text</span>
79
+ <span id="saveStat" title="Edits save to the app's contract automatically" hidden>Auto-save on</span>
58
80
  <button class="primary" id="exportBtn" disabled>Export SVG</button>
59
81
  </header>
60
82
  <div id="wrap">
@@ -66,6 +88,7 @@
66
88
  <input type="range" id="zoom" min="10" max="800" value="100" aria-label="Zoom">
67
89
  <span id="zpct">100%</span>
68
90
  </div>
91
+ <div id="toast" role="status"><span id="toastMsg"></span><button class="ghost" id="undoBtn">Undo</button></div>
69
92
  </div>
70
93
  <aside>
71
94
  <div>
@@ -83,7 +106,9 @@
83
106
  <div>
84
107
  <h3>Confidence</h3>
85
108
  <label class="ck" id="weakRow" hidden><input type="checkbox" id="weakOnly"><span>Isolate weak traces</span><span class="ct" id="cWeak">0</span></label>
86
- <div class="muted" id="weakNote">No weak traces — read deterministically.</div>
109
+ <div class="muted" id="revLine" hidden>Reviewed 0/0</div>
110
+ <div id="weakList" hidden></div>
111
+ <div class="muted" id="weakNote">No weak traces — traced exactly from the vector PDF (no AI involved).</div>
87
112
  </div>
88
113
  </aside>
89
114
  </div>
@@ -100,6 +125,12 @@
100
125
 
101
126
  let sheet = null;
102
127
  let view = { x: 0, y: 0, w: 100, h: 100 };
128
+ let C = null; // the full contract (PUT body) — `sheet` is a reference into it
129
+ // Edits are only possible against the app's live contract store; a static ?src= view stays read-only.
130
+ const editable = !!app && src.indexOf('/api/contract/') === 0;
131
+ let initialWeak = 0; // M in "Reviewed N/M" — the weak count when the drawing loaded
132
+ let lastDeleted = null; // single-level undo: { el, i } of the last deleted trace
133
+ let toastT = 0;
103
134
 
104
135
  function showState(msg) { $('stateMsg').textContent = msg; $('state').classList.add('show'); $('state').querySelector('.spin').style.display = msg ? 'none' : ''; }
105
136
  function hideState() { $('state').classList.remove('show'); }
@@ -257,20 +288,166 @@
257
288
  }
258
289
  }
259
290
 
291
+ // ── auto-save (mirrors steel-editor: debounce-chip + flushContract for the shell's Approve) ──
292
+ let saveT = 0;
293
+ function setSaved(state) {
294
+ if (!editable) return;
295
+ const el = $('saveStat'); el.hidden = false; el.classList.remove('dirty', 'err');
296
+ if (state === 'dirty') { el.classList.add('dirty'); el.textContent = 'Saving…'; }
297
+ else if (state === 'err') { el.classList.add('err'); el.textContent = 'Save failed'; }
298
+ else el.textContent = 'Saved';
299
+ }
300
+ // PUT the full contract as the server-side draft — the copy Approve bakes. Saves are
301
+ // SERIALIZED through a generation-checked queue: overlapping PUTs could land out of order
302
+ // (last-write-wins on the server), letting an older in-flight snapshot overwrite a newer one
303
+ // right before Approve. Each queued write stringifies C at send time, and a write that has
304
+ // been superseded by a newer request skips itself — so the queue's tail always leaves the
305
+ // LATEST state on the server. Returns the queue tail (ok boolean).
306
+ let saveGen = 0;
307
+ let saveQueue = Promise.resolve(true);
308
+ async function doPut() {
309
+ try {
310
+ const res = await fetch('/api/contract/' + encodeURIComponent(app), {
311
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
312
+ });
313
+ setSaved(res.ok ? 'ok' : 'err');
314
+ if (!res.ok) console.error('server save rejected (' + res.status + ')', await res.text().catch(() => ''));
315
+ return res.ok;
316
+ } catch (e) { setSaved('err'); console.error('server save failed', e); return false; }
317
+ }
318
+ function persistServer() {
319
+ if (!editable || !C) return Promise.resolve(true);
320
+ const gen = ++saveGen;
321
+ saveQueue = saveQueue.then(() => (gen === saveGen ? doPut() : true)); // superseded → the newer write carries C
322
+ return saveQueue;
323
+ }
324
+ function scheduleSave() { if (!editable) return; setSaved('dirty'); clearTimeout(saveT); saveT = setTimeout(persistServer, 500); }
325
+ // The shell's Approve button awaits this before baking — throw so a rejected save aborts the bake.
326
+ window.flushContract = async function flushContract() {
327
+ if (!editable) return;
328
+ clearTimeout(saveT);
329
+ const ok = await persistServer(); // queued behind any in-flight write → latest state, in order
330
+ if (!ok) throw new Error('Save failed — the edited contract was rejected; fix it before Approve');
331
+ };
332
+
333
+ // ── weak-trace review: Accept clears the flag (absent = trusted), Delete removes with 1-level Undo ──
334
+ function isWeak(el) { return el.confidence != null && el.confidence < WEAK; }
335
+ function elLabel(el) {
336
+ if (el.kind === 'text') return '“' + (el.text || '') + '”';
337
+ return (el.kind || 'trace') + ' · ' + (el.id || '?');
338
+ }
339
+ function acceptEl(id) {
340
+ const el = sheet.elements.find((e) => e.id === id);
341
+ if (!el) return;
342
+ delete el.confidence; // schema: absent = trusted — cleaner than a magic 1.0
343
+ afterEdit();
344
+ }
345
+ function deleteEl(id) {
346
+ const i = sheet.elements.findIndex((e) => e.id === id);
347
+ if (i < 0) return;
348
+ // Prune the id from any group memberships too — a dangling groups[].elementIds reference
349
+ // would survive PUT/Approve (the schema only checks it's a string array). Remember each
350
+ // pruned spot so Undo restores memberships along with the element.
351
+ const memberships = [];
352
+ for (const g of sheet.groups || []) {
353
+ const gi = (g.elementIds || []).indexOf(id);
354
+ if (gi >= 0) { memberships.push({ g, gi }); g.elementIds.splice(gi, 1); }
355
+ }
356
+ lastDeleted = { el: sheet.elements[i], i, memberships };
357
+ sheet.elements.splice(i, 1);
358
+ showToast('Trace deleted');
359
+ afterEdit();
360
+ }
361
+ function undoDelete() {
362
+ if (!lastDeleted) return;
363
+ sheet.elements.splice(Math.min(lastDeleted.i, sheet.elements.length), 0, lastDeleted.el);
364
+ for (const m of lastDeleted.memberships) m.g.elementIds.splice(Math.min(m.gi, m.g.elementIds.length), 0, lastDeleted.el.id);
365
+ lastDeleted = null;
366
+ hideToast();
367
+ afterEdit();
368
+ }
369
+ function showToast(msg) {
370
+ $('toastMsg').textContent = msg;
371
+ $('toast').classList.add('show');
372
+ clearTimeout(toastT); toastT = setTimeout(hideToast, 6000);
373
+ }
374
+ function hideToast() { $('toast').classList.remove('show'); clearTimeout(toastT); }
375
+ $('undoBtn').addEventListener('click', undoDelete);
376
+
377
+ function buildWeakList() {
378
+ const box = $('weakList');
379
+ while (box.firstChild) box.removeChild(box.firstChild);
380
+ const weak = editable ? sheet.elements.filter(isWeak) : [];
381
+ box.hidden = weak.length === 0;
382
+ for (const el of weak) {
383
+ const row = document.createElement('div'); row.className = 'wrow'; row.dataset.id = el.id || '';
384
+ const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = elLabel(el);
385
+ lbl.title = 'Show this trace on the drawing';
386
+ lbl.addEventListener('click', () => locateOnCanvas(el.id));
387
+ const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = Math.round((el.confidence || 0) * 100) + '%';
388
+ const ok = document.createElement('button'); ok.className = 'ghost'; ok.textContent = 'Accept';
389
+ ok.title = 'Mark this trace as correct — clears its low-confidence flag.';
390
+ ok.addEventListener('click', () => acceptEl(el.id));
391
+ const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Delete';
392
+ del.title = 'Remove this trace from the drawing (Undo available).';
393
+ del.addEventListener('click', () => deleteEl(el.id));
394
+ row.append(lbl, pct, ok, del); box.appendChild(row);
395
+ }
396
+ }
397
+ // Locate: list row → flash the trace on the canvas; canvas click on a weak trace → flash its row.
398
+ function locateOnCanvas(id) {
399
+ const node = [...svg.children].find((n) => n.dataset.id === id);
400
+ if (!node) return;
401
+ node.classList.add('locfl');
402
+ setTimeout(() => node.classList.remove('locfl'), 1200);
403
+ }
404
+ svg.addEventListener('click', (e) => {
405
+ const id = e.target && e.target.dataset && e.target.dataset.id;
406
+ if (!id || !e.target.classList.contains('weak')) return;
407
+ const row = [...document.querySelectorAll('#weakList .wrow')].find((r) => r.dataset.id === id);
408
+ if (!row) return;
409
+ row.scrollIntoView({ block: 'nearest' });
410
+ row.classList.add('flash');
411
+ setTimeout(() => row.classList.remove('flash'), 1200);
412
+ });
413
+
414
+ // Header stats + Confidence section (counts, note wording, Reviewed N/M) — rerun after every edit.
415
+ function refreshStats() {
416
+ const c = counts();
417
+ $('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
418
+ $('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
419
+ $('cWeak').textContent = c.weak;
420
+ // The zero-weak wording must match HOW the drawing was read: after a vision read the honest
421
+ // message is "scored above the bar, still spot-check", never the exact-extraction claim
422
+ // (the extractor field is the provenance; absent/non-vision = the no-AI deterministic path).
423
+ const visionRead = /^vision/.test((C && C.source && C.source.extractor) || '');
424
+ $('weakNote').textContent = c.weak
425
+ ? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' scored below ' + WEAK + ' confidence.')
426
+ : (visionRead ? 'No weak traces — every element scored above ' + WEAK + '. Spot-check against the original.' : 'No weak traces — traced exactly from the vector PDF (no AI involved).');
427
+ $('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
428
+ if (c.weak === 0) $('weakOnly').checked = false; // nothing left to isolate — release the filter
429
+ // Reviewed N/M — M is the weak count at load; Accept and Delete both count as reviewed.
430
+ const reviewed = Math.max(0, initialWeak - c.weak);
431
+ $('revLine').hidden = !editable || initialWeak === 0;
432
+ $('revLine').textContent = c.weak === 0 && initialWeak > 0
433
+ ? 'All ' + initialWeak + ' reviewed'
434
+ : 'Reviewed ' + reviewed + '/' + initialWeak;
435
+ }
436
+ function afterEdit() { refreshStats(); render(); buildWeakList(); scheduleSave(); }
437
+
260
438
  function load(contract) {
261
439
  if (!contract || !Array.isArray(contract.sheets) || !contract.sheets.length) { showState('This app has no vectorized drawing yet. Drop a PDF and re-bake to read one.'); return; }
440
+ C = contract;
262
441
  sheet = contract.sheets[contract.active || 0] || contract.sheets[0];
263
442
  if (!sheet || !Array.isArray(sheet.elements)) { showState('The drawing has no elements.'); return; }
264
- const c = counts();
443
+ initialWeak = sheet.elements.filter(isWeak).length;
265
444
  const name = (contract.source && contract.source.name) ? contract.source.name : (sheet.label || 'drawing');
266
445
  $('title').textContent = name;
267
446
  document.title = 'Vectorize — ' + name;
268
- $('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
269
- $('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
270
- $('cWeak').textContent = c.weak;
271
- $('weakNote').textContent = c.weak ? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' below ' + WEAK + ' confidence.') : 'No weak traces — read deterministically.';
272
- $('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
447
+ if (editable) setSaved('ok');
448
+ refreshStats();
273
449
  buildLayers();
450
+ buildWeakList();
274
451
  render(); fit(); $('exportBtn').disabled = false; hideState();
275
452
  }
276
453
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.60.0",
3
+ "version": "0.61.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": {