@floless/app 0.58.0 → 0.59.1
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 +15 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +5 -3
- package/dist/skills/floless-app-rebake/SKILL.md +7 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +62 -22
- package/dist/web/app.css +15 -0
- package/dist/web/aware.js +165 -14
- package/dist/web/steel-filter-core.js +44 -13
- package/dist/web/steel-filter.html +31 -19
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.59.1" : 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.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.59.1" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -59194,6 +59194,14 @@ function rollupDetails(details) {
|
|
|
59194
59194
|
}
|
|
59195
59195
|
|
|
59196
59196
|
// contract-score.ts
|
|
59197
|
+
function isFilterMissing(contract) {
|
|
59198
|
+
const f = contract?.filter;
|
|
59199
|
+
if (!f || typeof f !== "object") return true;
|
|
59200
|
+
const sheets = f.sheets;
|
|
59201
|
+
if (Array.isArray(sheets) && sheets.length > 0) return false;
|
|
59202
|
+
if (f.page) return false;
|
|
59203
|
+
return true;
|
|
59204
|
+
}
|
|
59197
59205
|
var ORDER = { rfi: 0, low: 1, med: 2, high: 3, verified: 4 };
|
|
59198
59206
|
var factor = (factors, key, state2) => factors.find((f) => f.key === key && (state2 === void 0 || f.state === state2));
|
|
59199
59207
|
function reasonFor(m) {
|
|
@@ -59231,7 +59239,8 @@ function scoreContract2(contractInput) {
|
|
|
59231
59239
|
members: s.byMember.length,
|
|
59232
59240
|
rfiCount: s.overall.rfiCount,
|
|
59233
59241
|
counts,
|
|
59234
|
-
worklist
|
|
59242
|
+
worklist,
|
|
59243
|
+
filterMissing: isFilterMissing(contractInput)
|
|
59235
59244
|
};
|
|
59236
59245
|
}
|
|
59237
59246
|
|
|
@@ -64500,7 +64509,9 @@ async function startServer() {
|
|
|
64500
64509
|
const REBAKE_INSTRUCTION = "Re-read the attached drawing and re-bake the extracted values into this app's config.yaml (the literals the deterministic run uses), then `aware app compile` the app and tell me to approve the new lock. Do not change node logic \u2014 only the baked config values.";
|
|
64501
64510
|
app.post(
|
|
64502
64511
|
"/api/rebake",
|
|
64503
|
-
|
|
64512
|
+
// A drawing SET is now multiple files (up to 8) — a single 25 MB cap would 413 a legit multi-sheet
|
|
64513
|
+
// rebake. 96 MB fits several large structural PDFs (base64-inflated); localhost, single-user, transient.
|
|
64514
|
+
{ bodyLimit: 96 * 1024 * 1024 },
|
|
64504
64515
|
async (req, reply) => {
|
|
64505
64516
|
const { appId, inputName, instruction, snapshots } = req.body ?? {};
|
|
64506
64517
|
const sourceName = typeof req.body?.sourceName === "string" ? req.body.sourceName : void 0;
|
|
@@ -61,11 +61,12 @@
|
|
|
61
61
|
"properties": {
|
|
62
62
|
"w": { "type": "number", "minimum": 1 },
|
|
63
63
|
"h": { "type": "number", "minimum": 1 },
|
|
64
|
-
"bg_b64": { "type": "string", "description": "Base64 JPEG of the page (title block excluded). CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed." }
|
|
64
|
+
"bg_b64": { "type": "string", "description": "Base64 JPEG of the page (title block excluded). CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed." },
|
|
65
|
+
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] in vector-frame coords: the region `bg_b64` was clipped to, so the viewer registers the raster to it (not the whole page). Absent = covers the steelBbox (legacy)." }
|
|
65
66
|
}
|
|
66
67
|
},
|
|
67
68
|
"steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of the steel linework, for Zoom-to-steel." },
|
|
68
|
-
"mode": { "enum": ["thickness", "layer", "color", "thickness+layer", "thickness+color"], "description": "Active match
|
|
69
|
+
"mode": { "enum": ["", "thickness", "layer", "color", "thickness+layer", "thickness+color", "layer+color", "thickness+layer+color"], "description": "Active match dimensions, '+'-joined and AND-ed (canonical order: thickness, layer, color). Any combo of layer/thickness/color; \"\" = match nothing (all boxes unchecked — a valid saved state)." },
|
|
69
70
|
"hide_title_block": { "type": "boolean" },
|
|
70
71
|
"layers": {
|
|
71
72
|
"type": "array",
|
|
@@ -142,7 +143,8 @@
|
|
|
142
143
|
"properties": {
|
|
143
144
|
"w": { "type": "number", "minimum": 1 },
|
|
144
145
|
"h": { "type": "number", "minimum": 1 },
|
|
145
|
-
"bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." }
|
|
146
|
+
"bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." },
|
|
147
|
+
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] in the same vector-frame coords as element `d` paths: the region `bg_b64` was clipped to. The viewer registers the raster to THIS rect (not the whole page) so it aligns 1:1 with the linework. Absent = the raster covers the steelBbox (legacy)." }
|
|
146
148
|
}
|
|
147
149
|
},
|
|
148
150
|
"steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of this sheet's steel linework, for Zoom-to-steel." },
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: floless-app-rebake
|
|
3
3
|
description: This skill should be used when the user swaps the source drawing of a BAKED floless.app workflow and wants it re-read — the "Re-read & re-bake" (B3) loop for Visual Inputs. Triggers on a pending floless `rebake` request (queued from the app's "Re-read & re-bake ▸" button), or asks like "re-bake screenshot-to-saved-settings with this new schedule", "I swapped the drawing, re-read it", "re-extract and re-bake the config". It teaches the host AI to read the new image, re-extract its values, rewrite ONLY the app's config.yaml baked literals, recompile, and ask the user to approve — all at COMPOSE time (AWARE forbids reading a drawing during a run).
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.2.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Re-read & re-bake a baked Visual Input (B3)
|
|
@@ -31,10 +31,12 @@ up here.
|
|
|
31
31
|
|
|
32
32
|
1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
|
|
33
33
|
`type: "rebake"`. It carries `appId`, the `inputName` (the baked input, e.g. `drawing`),
|
|
34
|
-
an `instruction`, and `snapshots: ["<abs path
|
|
35
|
-
ask in prose with
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
an `instruction`, and `snapshots: ["<abs path>", …]` — **one OR MORE** new files (the user can
|
|
35
|
+
attach a set). (The user may also just ask in prose with image(s) attached — same procedure, skip
|
|
36
|
+
the request lookup.)
|
|
37
|
+
2. **Read the new drawing(s).** Open **every** file in `snapshots[]` (real PNG/JPEG/WebP/PDF on
|
|
38
|
+
disk) — not just the first. Read them with your own vision as one combined set — this is the
|
|
39
|
+
compose-time extraction.
|
|
38
40
|
3. **Re-extract the same schema the app already bakes.** Open the app source +
|
|
39
41
|
`config.yaml` under `~/.aware/apps/<appId>/` (and the editable copy under `demos/<appId>/`
|
|
40
42
|
if present). Identify the baked literals (e.g. `config.schedule-rows`). Re-extract **the same
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: floless-app-steel-takeoff
|
|
3
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-model` 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-model", "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
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.3.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
|
|
@@ -31,15 +31,29 @@ but do not attempt them here.
|
|
|
31
31
|
|
|
32
32
|
## The loop
|
|
33
33
|
|
|
34
|
-
### 1. Find the drawing
|
|
34
|
+
### 1. Find the drawing(s)
|
|
35
35
|
|
|
36
36
|
From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
|
|
37
|
-
`type:"rebake"` and `appId:"steel-model"` carries `snapshots: ["<abs path>"]`
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
`type:"rebake"` and `appId:"steel-model"` carries `snapshots: ["<abs path>", …]` and optionally
|
|
38
|
+
`sourceName` (the original filename of the first file — use it for the `source.name` provenance
|
|
39
|
+
field). **`snapshots` may hold MULTIPLE files** — the user can attach a whole set (e.g. separate
|
|
40
|
+
PDFs for S-201 / S-202 / S-301, or one multi-sheet PDF plus a detail sheet). **Read every file in
|
|
41
|
+
`snapshots[]`, not just the first** — treat them as one combined set: run the sheet index (step 2.1)
|
|
42
|
+
across all of them, and the per-sheet filter pass (1b) and member read span every file. If the user
|
|
43
|
+
just attaches or points at a drawing in prose, skip the lookup and proceed from that path (again,
|
|
44
|
+
handle all the files they gave you).
|
|
45
|
+
|
|
46
|
+
### 1b. Filter step — isolate the steel linework FIRST (the `filter` node) — MANDATORY, DON'T SKIP
|
|
47
|
+
|
|
48
|
+
> 🚦 **This is a hard gate, not an optional pre-stage. You MUST populate the `filter` block and hand
|
|
49
|
+
> the Filter node to the user to Save BEFORE you read any members (step 2).** The member read is
|
|
50
|
+
> *downstream of* the filter — skipping straight to members is the #1 failure of this skill: the run
|
|
51
|
+
> then renders the shipped example filter (`S-ANNO-BEAM-STEL` / `S-ANNO-GRID` / …) as if it were the
|
|
52
|
+
> user's drawing, and the members get bound by a length heuristic instead of the real steel linework.
|
|
53
|
+
> The `/score` endpoint reports `filterMissing: true` until you've done this — treat that as a blocking
|
|
54
|
+
> worklist item (see step 3). Whenever the drawing has vector linework (any PDF `page.get_drawings()`
|
|
55
|
+
> returns paths), this step is required; a pure raster/photo with no vector paths is the only exception,
|
|
56
|
+
> and even then say so explicitly.
|
|
43
57
|
|
|
44
58
|
Before the heavy read, do **one** deterministic PyMuPDF pass **per framing-plan sheet** that enumerates
|
|
45
59
|
*every* drawing element by **layer · thickness · colour** and bakes it into the contract's `filter`
|
|
@@ -54,6 +68,11 @@ sheets (Foundation, each Floor, Roof). **Show the user the list and get an expli
|
|
|
54
68
|
"I found these framing plans: S-201, S-202, S-301 — filter all of these?" — before the pass. (The full
|
|
55
69
|
elevation/detail classification is the broader workflow; here we only need the plan set.)
|
|
56
70
|
|
|
71
|
+
⚠️ **This ONE plan-scope confirm covers both the filter pass AND the member read — they run on the
|
|
72
|
+
same sheet set. Getting the OK does NOT mean "go read members"; the very next thing you do is the
|
|
73
|
+
filter pass below, then hand off the Filter node. Do not let the confirm gate double as permission to
|
|
74
|
+
skip the filter** (that collision is exactly how a real session read 28 members with no filter block).
|
|
75
|
+
|
|
57
76
|
Then, for **each** confirmed plan sheet, one pass in display space (`page.rotation_matrix` — never mix
|
|
58
77
|
native/display coords):
|
|
59
78
|
|
|
@@ -61,10 +80,13 @@ native/display coords):
|
|
|
61
80
|
pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
|
|
62
81
|
2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
|
|
63
82
|
text:<string>, bbox:[x0,y0,x1,y1] }`.
|
|
64
|
-
3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64 }, steelBbox,
|
|
83
|
+
3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64, rect }, steelBbox,
|
|
65
84
|
elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
|
|
66
|
-
(**title block excluded** — CONFIDENTIAL, machine-local, never committed)
|
|
67
|
-
|
|
85
|
+
(**title block excluded** — CONFIDENTIAL, machine-local, never committed). **`page.rect` = the exact
|
|
86
|
+
clip `[x0,y0,x1,y1]` you rendered `bg_b64` from, in the SAME display-space coords as the element `d`
|
|
87
|
+
paths** — the viewer registers the raster to `rect` (not the whole page), so it aligns 1:1 with the
|
|
88
|
+
linework. `w`/`h` stay the full display-space page size (the vectors' coordinate frame). `steelBbox`
|
|
89
|
+
= the bbox of this sheet's steel-layer elements (Zoom-to-steel).
|
|
68
90
|
4. **Global facets (union across sheets)** — aggregate distinct `layers` / `thicknesses` / `colors`
|
|
69
91
|
with a summed `count` each, ACROSS all sheets, into the top-level `filter.layers/thicknesses/colors`
|
|
70
92
|
(NOT per-sheet). Round each element's `w` and the facet `w` the **same** way (matching is
|
|
@@ -74,17 +96,23 @@ native/display coords):
|
|
|
74
96
|
5. Set `filter.mode: "layer"`, `filter.active: 0`. **PUT** the contract
|
|
75
97
|
(`PUT /api/contract/steel-model`). On a re-read, preserve any existing per-facet `on` flags.
|
|
76
98
|
|
|
77
|
-
Then
|
|
78
|
-
**Save**. The saved `on` flags + `mode` are
|
|
79
|
-
passing `mode` + the global `on` flags).
|
|
99
|
+
**Then STOP and hand off — checkpoint before the member read.** Tell the user to open the **Filter**
|
|
100
|
+
node, flip through the sheets, narrow to the steel, and **Save**. The saved `on` flags + `mode` are
|
|
101
|
+
what step 2 reads — **per sheet** (each sheet's elements passing `mode` + the global `on` flags). Only
|
|
102
|
+
after the filter is baked (confirm with `GET /api/contract/steel-model` → a `filter` with `sheets`, or
|
|
103
|
+
`POST …/score` → `filterMissing: false`) do you proceed to the member read. The member read binds to
|
|
104
|
+
the user's kept steel; running it against a filter-less contract is the gap this checkpoint exists to
|
|
105
|
+
close.
|
|
80
106
|
|
|
81
107
|
### 2. Read it with your own vision — the heavy pipeline
|
|
82
108
|
|
|
83
|
-
> **Candidate geometry comes from the filter (1b)
|
|
84
|
-
>
|
|
85
|
-
>
|
|
86
|
-
>
|
|
87
|
-
>
|
|
109
|
+
> **Candidate geometry comes from the filter (1b) — which you already baked.** By the time you reach
|
|
110
|
+
> here, step 1b has run and the user has Saved the Filter node, so the contract carries a `filter`: the
|
|
111
|
+
> member candidate set **for each sheet** is that sheet's **kept** line elements
|
|
112
|
+
> (`filter.sheets[i].elements` whose layer/thickness/colour pass the GLOBAL `mode` + `on` flags) — bind
|
|
113
|
+
> labels to *those*, not to a length-filtered scan of the whole sheet. The length heuristic below is a
|
|
114
|
+
> **degraded fallback** for the raster-only exception in 1b (no vector paths to enumerate); if you find
|
|
115
|
+
> yourself using it on a vector PDF, you skipped 1b — go back and do it.
|
|
88
116
|
|
|
89
117
|
Follow the methodology condensed here (full detail + every gotcha in
|
|
90
118
|
`docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
|
|
@@ -185,9 +213,17 @@ Content-Type: application/json
|
|
|
185
213
|
{ "contract": <your draft contract>, "target": <target_confidence, e.g. 70> }
|
|
186
214
|
```
|
|
187
215
|
|
|
188
|
-
Returns `{ score, target, meetsTarget, gap, rfiCount, counts, worklist }` — the SAME
|
|
189
|
-
editor's Confidence chip shows. `worklist` is the rfi/low/med members dragging the score,
|
|
190
|
-
(heaviest first within a band), each with a `reason`. (Omit `contract` to score the stored
|
|
216
|
+
Returns `{ score, target, meetsTarget, gap, rfiCount, counts, worklist, filterMissing }` — the SAME
|
|
217
|
+
aggregate the editor's Confidence chip shows. `worklist` is the rfi/low/med members dragging the score,
|
|
218
|
+
worst-first (heaviest first within a band), each with a `reason`. (Omit `contract` to score the stored
|
|
219
|
+
one.)
|
|
220
|
+
|
|
221
|
+
**`filterMissing` is the completeness gate — independent of the confidence score.** `true` means the
|
|
222
|
+
contract has no usable `filter` block: step 1b never ran, so the members were bound by the length
|
|
223
|
+
heuristic, not the user's steel. A filter-less contract can still hit `meetsTarget` (as one real
|
|
224
|
+
session did at 73%) — so **do not treat `meetsTarget` alone as "done."** If `filterMissing` is `true`,
|
|
225
|
+
go back to step 1b, bake the filter, and hand off the Filter node before continuing. Hand-off (step 6)
|
|
226
|
+
requires `filterMissing: false`.
|
|
191
227
|
|
|
192
228
|
**The loop** — iterate at COMPOSE time until the goal is met:
|
|
193
229
|
|
|
@@ -404,6 +440,10 @@ offending fields, and re-PUT. You can read back the current contract with
|
|
|
404
440
|
|
|
405
441
|
### 6. Hand off to the user
|
|
406
442
|
|
|
443
|
+
**Precondition:** `POST …/score` returns `filterMissing: false` (the Filter node was baked in 1b and the
|
|
444
|
+
member read bound to it). If it's still `true`, you are not done — return to step 1b. Do not present a
|
|
445
|
+
takeoff whose filter was skipped as finished.
|
|
446
|
+
|
|
407
447
|
Tell the user:
|
|
408
448
|
- What you read (e.g. "read 14 beams and 6 columns on sheets S-201/S-202; 3 MF members await
|
|
409
449
|
Frame-Elevation resolution; aggregate confidence 78% — above your 70% target").
|
package/dist/web/app.css
CHANGED
|
@@ -2854,6 +2854,21 @@ body {
|
|
|
2854
2854
|
.fm-file-replace, .fm-file-clear { font-size: 10px; padding: 3px 8px; background: var(--surface); border: 1px solid var(--border-strong); color: var(--text-muted); border-radius: 3px; cursor: pointer; font-family: var(--ui); }
|
|
2855
2855
|
.fm-file-replace:hover { color: var(--text); border-color: var(--accent-dim); }
|
|
2856
2856
|
.fm-file-clear:hover { color: var(--err); border-color: var(--err); }
|
|
2857
|
+
/* ===== Visual Input: MULTI image/PDF file field (e.g. a framing set) ===== */
|
|
2858
|
+
.fm-files { position: relative; }
|
|
2859
|
+
.fm-files .fm-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
2860
|
+
.fm-files .fm-drop.at-cap { cursor: not-allowed; border-style: solid; border-color: var(--border-strong); background: var(--surface-2); color: var(--text-dim); outline: none; }
|
|
2861
|
+
.fm-files-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
2862
|
+
.fm-files-list[hidden] { display: none; }
|
|
2863
|
+
.fm-filechip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 6px 4px 8px; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--surface-2); }
|
|
2864
|
+
.fm-filechip.is-reading { opacity: 0.6; }
|
|
2865
|
+
.fm-filechip-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--text); }
|
|
2866
|
+
.fm-filechip-del { flex: none; width: 16px; height: 16px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; line-height: 1; background: transparent; border: 1px solid var(--border-strong); border-radius: 2px; color: var(--text-muted); cursor: pointer; }
|
|
2867
|
+
.fm-filechip-del:hover, .fm-filechip-del:focus-visible { color: var(--err); border-color: var(--err); }
|
|
2868
|
+
/* Re-bake "currently loaded" context row (inert — not a field) */
|
|
2869
|
+
.rebake-current { display: flex; align-items: center; gap: 8px; padding: 7px 10px; margin-bottom: 12px; background: var(--surface-2); border-radius: 4px; }
|
|
2870
|
+
.rebake-current-label { flex: none; text-transform: uppercase; letter-spacing: 0.08em; font-size: 10px; color: var(--text-muted); }
|
|
2871
|
+
.rebake-current .fm-file-name { flex: 1; }
|
|
2857
2872
|
/* Visual Input chip on the canvas input node */
|
|
2858
2873
|
.agent-card .node-inputs .ni-pair-file { align-items: center; }
|
|
2859
2874
|
.agent-card .node-inputs .ni-file-glyph { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 15px; border: 1px solid var(--accent-dim); border-radius: 3px; background: var(--accent-soft); color: var(--accent); font: 700 8px/1 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
|
package/dist/web/aware.js
CHANGED
|
@@ -1386,26 +1386,43 @@
|
|
|
1386
1386
|
const app = currentId && apps.get(currentId);
|
|
1387
1387
|
if (!app || !app.rebakeInput) return;
|
|
1388
1388
|
const name = app.rebakeInput;
|
|
1389
|
+
// Show what's currently baked in (the source the run uses) so the user knows what they're
|
|
1390
|
+
// replacing. Contract-backed apps carry it in source.name; suppress the shipped example, and
|
|
1391
|
+
// skip silently for apps that bake into config (no contract) — the "if there is anything" case.
|
|
1392
|
+
let current = '';
|
|
1393
|
+
try {
|
|
1394
|
+
const cr = await fetch(`/api/contract/${encodeURIComponent(app.id)}`);
|
|
1395
|
+
if (cr.ok) {
|
|
1396
|
+
const doc = await cr.json().catch(() => null);
|
|
1397
|
+
const nm = doc && doc.source && typeof doc.source.name === 'string' ? doc.source.name : '';
|
|
1398
|
+
if (nm && !/^example\b/i.test(nm)) current = nm;
|
|
1399
|
+
}
|
|
1400
|
+
} catch { /* no current source — fine, just don't show the row */ }
|
|
1401
|
+
const fields = [];
|
|
1402
|
+
if (current) fields.push({ type: 'current', name: '__current', label: 'Currently loaded', value: current });
|
|
1403
|
+
fields.push({ name, label: `${name} — new source drawing(s)`, type: 'files', accept: ['pdf', 'png', 'jpg', 'webp'], required: true });
|
|
1389
1404
|
const res = await formModal({
|
|
1390
1405
|
title: `Re-read & re-bake · ${app.displayName}`,
|
|
1391
|
-
sub: 'Attach the new
|
|
1392
|
-
fields
|
|
1406
|
+
sub: 'Attach the new drawings (PDF or image) — one or several sheets. Your terminal AI re-reads them and updates this workflow, then asks you to approve — nothing changes until you do.',
|
|
1407
|
+
fields,
|
|
1393
1408
|
okLabel: 'Queue re-bake',
|
|
1394
1409
|
});
|
|
1395
1410
|
if (!res) return;
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
1411
|
+
const files = Array.isArray(res[name]) ? res[name] : [];
|
|
1412
|
+
if (!files.length) { showToast('attach a drawing to re-bake', 'warn'); return; }
|
|
1413
|
+
const sourceName = files[0].name || undefined; // provenance: the first file's name
|
|
1414
|
+
const n = files.length;
|
|
1399
1415
|
try {
|
|
1400
1416
|
const r = await fetch('/api/rebake', {
|
|
1401
1417
|
method: 'POST',
|
|
1402
1418
|
headers: { 'content-type': 'application/json' },
|
|
1403
|
-
body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots:
|
|
1419
|
+
body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: files.map((f) => ({ name: f.name, dataUrl: f.dataUrl })) }),
|
|
1404
1420
|
});
|
|
1421
|
+
if (r.status === 413) { showToast('those files are too large to queue together — attach fewer or smaller sheets', 'warn'); return; }
|
|
1405
1422
|
const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
|
|
1406
1423
|
if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
|
|
1407
|
-
showToast(
|
|
1408
|
-
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong
|
|
1424
|
+
showToast(`Queued — your terminal AI will re-read ${n} drawing${n > 1 ? 's' : ''} and update this workflow, then ask you to approve.`, 'ok');
|
|
1425
|
+
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong> (${n} file${n > 1 ? 's' : ''}). Your terminal AI will re-read the drawing${n > 1 ? 's' : ''}, update this workflow, and ask you to approve the new lock.`);
|
|
1409
1426
|
} catch { showToast('could not queue re-bake', 'err'); }
|
|
1410
1427
|
}
|
|
1411
1428
|
|
|
@@ -2250,6 +2267,13 @@
|
|
|
2250
2267
|
if (f.type === 'checkbox') {
|
|
2251
2268
|
return `<label class="modal-check" for="${id}"><input type="checkbox" id="${id}" data-fm-check="${escapeHtml(f.name)}"${f.value ? ' checked' : ''}><span>${escapeHtml(f.label || f.name)}</span></label>`;
|
|
2252
2269
|
}
|
|
2270
|
+
// Read-only "what's currently loaded" context row (e.g. the source drawing already
|
|
2271
|
+
// baked into a re-bake app). Inert — a muted label + glyph + filename, not a field.
|
|
2272
|
+
if (f.type === 'current') {
|
|
2273
|
+
const cext = (/\.([a-z0-9]+)$/i.exec(f.value || '') || [])[1];
|
|
2274
|
+
const cglyph = String(cext || '').toLowerCase() === 'pdf' ? 'pdf' : 'img';
|
|
2275
|
+
return `<div class="rebake-current"><span class="rebake-current-label">${escapeHtml(f.label || 'Currently loaded')}</span><span class="fm-file-glyph">${cglyph}</span><span class="fm-file-name" title="${escapeAttr(f.value)}">${escapeHtml(f.value)}</span></div>`;
|
|
2276
|
+
}
|
|
2253
2277
|
let ctl;
|
|
2254
2278
|
if (f.type === 'images') {
|
|
2255
2279
|
ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
|
|
@@ -2266,6 +2290,17 @@
|
|
|
2266
2290
|
<div class="fm-drop" role="button" tabindex="0" aria-label="Attach an image or PDF"></div>
|
|
2267
2291
|
<input type="file" accept="${escapeAttr(acceptAttr)}" class="fm-file-input" tabindex="-1" aria-hidden="true">
|
|
2268
2292
|
</div>`;
|
|
2293
|
+
} else if (f.type === 'files') {
|
|
2294
|
+
// MULTI image/PDF Visual Input (e.g. a whole framing set). Persistent drop zone +
|
|
2295
|
+
// a wrap-flow chip list below; drag-drop + click + paste; up to 8 (server cap).
|
|
2296
|
+
const accepts = (f.accept && f.accept.length) ? f.accept : ['pdf', 'png', 'jpg', 'webp'];
|
|
2297
|
+
const acceptAttr = accepts.map((e) => '.' + String(e).replace(/^\./, '')).join(',');
|
|
2298
|
+
const hint = accepts.join(', ').toUpperCase();
|
|
2299
|
+
ctl = `<div class="fm-files" data-fm-files="${escapeAttr(f.name)}" data-accept-hint="${escapeAttr(hint)}"${f.required ? ' data-required="1"' : ''}>
|
|
2300
|
+
<div class="fm-drop" role="button" tabindex="0" aria-label="Attach drawings — drop or click"><span class="fm-drop-main">Drop or click to attach files</span><span class="fm-drop-hint">${escapeHtml(hint)} · paste also works</span></div>
|
|
2301
|
+
<input type="file" accept="${escapeAttr(acceptAttr)}" multiple class="fm-file-input" tabindex="-1" aria-hidden="true">
|
|
2302
|
+
<div class="fm-files-list" aria-live="polite" hidden></div>
|
|
2303
|
+
</div>`;
|
|
2269
2304
|
} else {
|
|
2270
2305
|
ctl = f.multiline
|
|
2271
2306
|
? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
|
|
@@ -2281,10 +2316,18 @@
|
|
|
2281
2316
|
$ok.classList.toggle('primary', !danger);
|
|
2282
2317
|
$cancel.classList.toggle('primary', !!danger);
|
|
2283
2318
|
showModal($formModal);
|
|
2284
|
-
|
|
2319
|
+
// Prefer a real text input; else a file drop zone (so keyboard users land somewhere
|
|
2320
|
+
// actionable rather than a possibly-disabled OK button).
|
|
2321
|
+
const first = $body.querySelector('input:not(.fm-file-input),textarea')
|
|
2322
|
+
|| $body.querySelector('.fm-files .fm-drop, .fm-file .fm-drop, .fm-images .fm-drop');
|
|
2285
2323
|
if (first) { first.focus(); if (first.select) first.select(); }
|
|
2286
2324
|
else if (danger) $cancel.focus();
|
|
2287
2325
|
else $ok.focus();
|
|
2326
|
+
// OK-gate probes: required consent checks (below) AND required multi-file fields must be
|
|
2327
|
+
// satisfied. Declared here so the fm-files setup can re-run the gate; wired after all setups.
|
|
2328
|
+
let runGate = () => {};
|
|
2329
|
+
const fileGateProbes = [];
|
|
2330
|
+
let modalClosed = false; // set in done(); guards async FileReader callbacks from touching a closed modal
|
|
2288
2331
|
// ── images-field setup ──────────────────────────────────────────────────────
|
|
2289
2332
|
const imageStates = new Map();
|
|
2290
2333
|
$body.querySelectorAll('.fm-images').forEach((box) => {
|
|
@@ -2362,6 +2405,90 @@
|
|
|
2362
2405
|
fileStates.set(name, { st, setFile });
|
|
2363
2406
|
render();
|
|
2364
2407
|
});
|
|
2408
|
+
// ── multi-file field setup (image/PDF set, e.g. a framing set) ─────────────────
|
|
2409
|
+
const fileListStates = new Map();
|
|
2410
|
+
$body.querySelectorAll('.fm-files').forEach((box) => {
|
|
2411
|
+
const name = box.dataset.fmFiles;
|
|
2412
|
+
const MAX = 8;
|
|
2413
|
+
const hint = box.dataset.acceptHint || 'PDF, PNG, JPG, WEBP';
|
|
2414
|
+
const acceptExts = hint.toLowerCase().split(/[,\s]+/).filter(Boolean);
|
|
2415
|
+
const state = []; // { name, ext, dataUrl, reading }
|
|
2416
|
+
const drop = box.querySelector('.fm-drop');
|
|
2417
|
+
const main = drop.querySelector('.fm-drop-main');
|
|
2418
|
+
const hintEl = drop.querySelector('.fm-drop-hint');
|
|
2419
|
+
const fileInput = box.querySelector('.fm-file-input');
|
|
2420
|
+
const list = box.querySelector('.fm-files-list');
|
|
2421
|
+
const isFileDrag = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
|
|
2422
|
+
// Prefer the filename extension; fall back to the MIME type for a no-name paste. Normalize
|
|
2423
|
+
// jpeg→jpg so a `.jpeg` file (or a pasted image/jpeg) is accepted like the server does.
|
|
2424
|
+
const extOf = (fn, type) => {
|
|
2425
|
+
const m = /\.([a-z0-9]+)$/i.exec(fn || '');
|
|
2426
|
+
let e = (m ? m[1] : '').toLowerCase();
|
|
2427
|
+
if (!e && type) e = type === 'application/pdf' ? 'pdf' : type.startsWith('image/') ? type.slice(6) : '';
|
|
2428
|
+
return e === 'jpeg' ? 'jpg' : e;
|
|
2429
|
+
};
|
|
2430
|
+
const readyCount = () => state.filter((s) => !s.reading && s.dataUrl).length;
|
|
2431
|
+
const syncDrop = () => {
|
|
2432
|
+
const n = state.length;
|
|
2433
|
+
const atCap = n >= MAX;
|
|
2434
|
+
drop.classList.toggle('at-cap', atCap);
|
|
2435
|
+
if (atCap) { main.textContent = `Maximum ${MAX} files attached`; hintEl.textContent = 'Remove one to add another'; }
|
|
2436
|
+
else if (n > 0) { main.textContent = `${n} of ${MAX} attached — drop or click to add more`; hintEl.textContent = `${hint} · paste also works`; }
|
|
2437
|
+
else { main.textContent = 'Drop or click to attach files'; hintEl.textContent = `${hint} · paste also works · up to ${MAX}`; }
|
|
2438
|
+
};
|
|
2439
|
+
const renderList = () => {
|
|
2440
|
+
list.hidden = state.length === 0;
|
|
2441
|
+
list.innerHTML = state.map((s, i) => s.reading
|
|
2442
|
+
? `<div class="fm-filechip is-reading"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name">Reading…</span></div>`
|
|
2443
|
+
: `<div class="fm-filechip"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name" title="${escapeAttr(s.name)}">${escapeHtml(s.name)}</span><button type="button" class="fm-filechip-del" data-i="${i}" aria-label="Remove ${escapeAttr(s.name)}">×</button></div>`
|
|
2444
|
+
).join('');
|
|
2445
|
+
list.querySelectorAll('.fm-filechip-del').forEach((b) => {
|
|
2446
|
+
b.onclick = () => {
|
|
2447
|
+
const idx = Number(b.dataset.i);
|
|
2448
|
+
state.splice(idx, 1);
|
|
2449
|
+
renderList(); syncDrop(); runGate();
|
|
2450
|
+
// Keep focus in the field, not lost to <body>: next chip's ×, else the drop zone.
|
|
2451
|
+
const dels = list.querySelectorAll('.fm-filechip-del');
|
|
2452
|
+
(dels[Math.min(idx, dels.length - 1)] || drop).focus();
|
|
2453
|
+
};
|
|
2454
|
+
});
|
|
2455
|
+
};
|
|
2456
|
+
const addFile = (file) => {
|
|
2457
|
+
if (!file) return;
|
|
2458
|
+
if (state.length >= MAX) { showToast(`Max ${MAX} files`, 'warn'); return; }
|
|
2459
|
+
const ext = extOf(file.name, file.type);
|
|
2460
|
+
if (acceptExts.length && !acceptExts.includes(ext)) { showToast(`${file.name || 'file'} — ${hint} only`, 'warn'); return; }
|
|
2461
|
+
const entry = { name: file.name || ('pasted.' + (ext || 'png')), ext, dataUrl: '', reading: true };
|
|
2462
|
+
state.push(entry);
|
|
2463
|
+
renderList(); syncDrop(); runGate(); // disable OK while this file reads (gate blocks on any reading)
|
|
2464
|
+
const reader = new FileReader();
|
|
2465
|
+
// Guard on modalClosed: a large read can finish after the modal is cancelled/closed; without
|
|
2466
|
+
// this the stale callback would mutate the SHARED $ok gate of the next formModal.
|
|
2467
|
+
reader.onload = () => { if (modalClosed) return; entry.dataUrl = reader.result; entry.reading = false; renderList(); syncDrop(); runGate(); };
|
|
2468
|
+
reader.onerror = () => { if (modalClosed) return; const i = state.indexOf(entry); if (i >= 0) state.splice(i, 1); renderList(); syncDrop(); runGate(); showToast(`could not read ${file.name || 'the file'}`, 'err'); };
|
|
2469
|
+
reader.readAsDataURL(file);
|
|
2470
|
+
};
|
|
2471
|
+
const addFiles = (files) => {
|
|
2472
|
+
const arr = Array.from(files || []);
|
|
2473
|
+
const room = Math.max(0, MAX - state.length);
|
|
2474
|
+
if (arr.length > room) showToast(`Max ${MAX} files`, 'warn');
|
|
2475
|
+
arr.slice(0, room).forEach(addFile);
|
|
2476
|
+
};
|
|
2477
|
+
drop.onclick = () => { if (state.length < MAX) fileInput.click(); };
|
|
2478
|
+
drop.onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && state.length < MAX) { e.preventDefault(); fileInput.click(); } };
|
|
2479
|
+
fileInput.onchange = () => { addFiles(fileInput.files); fileInput.value = ''; };
|
|
2480
|
+
// Native drag-drop: bind on the whole box (so the chip area isn't a dead zone), toggle the
|
|
2481
|
+
// visual on the inner drop. dragover MUST preventDefault or `drop` never fires.
|
|
2482
|
+
box.ondragenter = (e) => { if (isFileDrag(e)) { e.preventDefault(); if (state.length < MAX) { drop.classList.add('drag-over'); main.textContent = 'Drop to attach'; } } };
|
|
2483
|
+
box.ondragover = (e) => { if (isFileDrag(e)) { e.preventDefault(); e.dataTransfer.dropEffect = state.length < MAX ? 'copy' : 'none'; } };
|
|
2484
|
+
box.ondragleave = (e) => { if (!box.contains(e.relatedTarget)) { drop.classList.remove('drag-over'); syncDrop(); } };
|
|
2485
|
+
box.ondrop = (e) => { if (!isFileDrag(e)) return; e.preventDefault(); drop.classList.remove('drag-over'); addFiles(e.dataTransfer.files); syncDrop(); };
|
|
2486
|
+
fileListStates.set(name, { state, addFiles });
|
|
2487
|
+
// Gate probe returns 0 (blocks OK) while ANY file is still reading — so you can't submit a
|
|
2488
|
+
// partial set (collect() drops still-reading files); ready count only once all reads finish.
|
|
2489
|
+
if (box.dataset.required) fileGateProbes.push(() => (state.some((s) => s.reading) ? 0 : readyCount()));
|
|
2490
|
+
renderList(); syncDrop();
|
|
2491
|
+
});
|
|
2365
2492
|
// ── paste support: an image into the modal → the images field, else the file field ──
|
|
2366
2493
|
$body.onpaste = (e) => {
|
|
2367
2494
|
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
@@ -2386,6 +2513,14 @@
|
|
|
2386
2513
|
});
|
|
2387
2514
|
return;
|
|
2388
2515
|
}
|
|
2516
|
+
const filesBox = $body.querySelector('.fm-files');
|
|
2517
|
+
if (filesBox) {
|
|
2518
|
+
if (!fileItems.length) return;
|
|
2519
|
+
e.preventDefault();
|
|
2520
|
+
const entry = fileListStates.get(filesBox.dataset.fmFiles);
|
|
2521
|
+
if (entry) entry.addFiles(fileItems.map((it) => it.getAsFile()).filter(Boolean));
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2389
2524
|
const fileBox = $body.querySelector('.fm-file');
|
|
2390
2525
|
if (!fileBox || !fileItems.length) return;
|
|
2391
2526
|
e.preventDefault();
|
|
@@ -2393,15 +2528,26 @@
|
|
|
2393
2528
|
const file = fileItems[0].getAsFile();
|
|
2394
2529
|
if (entry && file) entry.setFile(file);
|
|
2395
2530
|
};
|
|
2531
|
+
// Stray-drop guard: a file dropped onto the modal but MISSING a drop zone must not
|
|
2532
|
+
// navigate the page to the file. The .fm-files box handles real drops; this only
|
|
2533
|
+
// neutralizes the misses.
|
|
2534
|
+
$body.ondragover = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault(); };
|
|
2535
|
+
$body.ondrop = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault(); };
|
|
2396
2536
|
// Required consent checkboxes (e.g. the fork liability terms) disable OK until ticked.
|
|
2397
2537
|
const requiredChecks = Array.from($body.querySelectorAll('[data-fm-check]')).filter((el) => {
|
|
2398
2538
|
const f = fields.find((x) => x.name === el.dataset.fmCheck);
|
|
2399
2539
|
return f && f.required;
|
|
2400
2540
|
});
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2541
|
+
// Unified OK gate: every required consent check ticked AND every required multi-file field
|
|
2542
|
+
// has ≥1 ready file. (runGate was declared up top so fm-files callbacks can re-run it.)
|
|
2543
|
+
if (requiredChecks.length || fileGateProbes.length) {
|
|
2544
|
+
runGate = () => {
|
|
2545
|
+
const checksOk = !requiredChecks.some((el) => !el.checked);
|
|
2546
|
+
const filesOk = !fileGateProbes.some((getN) => getN() < 1);
|
|
2547
|
+
$ok.disabled = !(checksOk && filesOk);
|
|
2548
|
+
};
|
|
2549
|
+
requiredChecks.forEach((el) => { el.onchange = runGate; });
|
|
2550
|
+
runGate();
|
|
2405
2551
|
}
|
|
2406
2552
|
const collect = () => {
|
|
2407
2553
|
const out = {};
|
|
@@ -2413,12 +2559,17 @@
|
|
|
2413
2559
|
// expose filename alongside so callers can capture provenance (e.g. rebake → sourceName)
|
|
2414
2560
|
out[name + '__filename'] = entry.st.name || '';
|
|
2415
2561
|
});
|
|
2562
|
+
// Multi-file field → array of { name, dataUrl } (only fully-read files).
|
|
2563
|
+
fileListStates.forEach((entry, name) => {
|
|
2564
|
+
out[name] = entry.state.filter((s) => !s.reading && s.dataUrl).map((s) => ({ name: s.name, dataUrl: s.dataUrl }));
|
|
2565
|
+
});
|
|
2416
2566
|
return out;
|
|
2417
2567
|
};
|
|
2418
2568
|
const done = (result) => {
|
|
2419
2569
|
_formModalOpen = false;
|
|
2570
|
+
modalClosed = true;
|
|
2420
2571
|
hideModal($formModal);
|
|
2421
|
-
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
|
|
2572
|
+
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null; $body.ondragover = null; $body.ondrop = null;
|
|
2422
2573
|
$ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); $ok.disabled = false; // reset chrome for the next caller
|
|
2423
2574
|
resolve(result);
|
|
2424
2575
|
};
|
|
@@ -10,24 +10,37 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Shapes:
|
|
12
12
|
* element : { kind:'line'|'text', layer:string, w:number, color:string, d?:string, text?:string, bbox?:number[], dashed?:boolean }
|
|
13
|
-
* mode : '
|
|
13
|
+
* mode : any '+'-joined combo of 'layer'/'thickness'/'color', AND-ed (e.g. 'thickness+layer').
|
|
14
|
+
* '' = match nothing. Canonical order = MODE_ORDER (keeps legacy 'thickness+…' strings).
|
|
14
15
|
* sel : { layers:Set<string>, thicknesses:Set<number>, colors:Set<string> }
|
|
15
16
|
* filter : { mode, layers:[{name,count,steel?,drawable?,on?}], thicknesses:[{w,count,on?}], colors:[{hex,count,on?}], elements:[…] }
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** Canonical dimension order for a mode string — keeps legacy 'thickness+layer'/'thickness+color'. */
|
|
20
|
+
export const MODE_ORDER = ['thickness', 'layer', 'color'];
|
|
21
|
+
|
|
22
|
+
/** The dimensions a mode activates, order-agnostic: 'thickness+layer' -> ['thickness','layer']. */
|
|
23
|
+
export function modeDims(mode) {
|
|
24
|
+
return String(mode || '').split('+').filter((d) => MODE_ORDER.includes(d));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the canonical mode string from a set/list of checked dimensions. */
|
|
28
|
+
export function canonicalMode(dims) {
|
|
29
|
+
const set = new Set(dims);
|
|
30
|
+
return MODE_ORDER.filter((d) => set.has(d)).join('+');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The active match predicate — an element passes when it matches EVERY checked dimension (AND).
|
|
35
|
+
* Zero checked dimensions match nothing (a valid, visible "0 shown" state, not a fall-through to all).
|
|
36
|
+
*/
|
|
19
37
|
export function matchesActive(el, mode, sel) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
case 'color': return byColor();
|
|
27
|
-
case 'thickness+layer': return byThk() && byLayer();
|
|
28
|
-
case 'thickness+color': return byThk() && byColor();
|
|
29
|
-
default: return false;
|
|
30
|
-
}
|
|
38
|
+
const dims = modeDims(mode);
|
|
39
|
+
if (!dims.length) return false;
|
|
40
|
+
return dims.every((d) =>
|
|
41
|
+
d === 'layer' ? sel.layers.has(el.layer)
|
|
42
|
+
: d === 'thickness' ? sel.thicknesses.has(el.w)
|
|
43
|
+
: sel.colors.has(el.color));
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
/**
|
|
@@ -66,6 +79,24 @@ export function countShown(elements, mode, sel) {
|
|
|
66
79
|
return n;
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Where a sheet's background raster (`page.bg_b64`) maps, in the same vector-frame coords as the
|
|
84
|
+
* elements' `d` paths — so the raster registers 1:1 with the linework. The producer clips the raster
|
|
85
|
+
* to the framing area and records that clip in `page.rect` ([x0,y0,x1,y1]); the renderer places the
|
|
86
|
+
* <image> there (preserveAspectRatio="none") instead of stretching it across the whole page.
|
|
87
|
+
*
|
|
88
|
+
* ponytail: `steelBbox` fallback bridges pre-`rect` contracts (the producer clipped the raster to the
|
|
89
|
+
* steel bbox, so it aligns exactly). Upgrade path: drop it once every baked contract carries page.rect.
|
|
90
|
+
*/
|
|
91
|
+
export function bgRect(sheet) {
|
|
92
|
+
const pg = (sheet && sheet.page) || {};
|
|
93
|
+
const valid = (r) => Array.isArray(r) && r.length === 4 && r[2] - r[0] > 0 && r[3] - r[1] > 0;
|
|
94
|
+
// Try each candidate in order — a degenerate page.rect must NOT skip a valid steelBbox.
|
|
95
|
+
const r = valid(pg.rect) ? pg.rect : (valid(sheet && sheet.steelBbox) ? sheet.steelBbox : null);
|
|
96
|
+
if (r) return { x: r[0], y: r[1], w: r[2] - r[0], h: r[3] - r[1] };
|
|
97
|
+
return { x: 0, y: 0, w: pg.w || 0, h: pg.h || 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
/**
|
|
70
101
|
* Write the live selection + mode back onto the filter block's `on` flags so it persists on the
|
|
71
102
|
* contract (what Save PUTs, and what the reader reads). Mutates and returns `filter`.
|
|
@@ -38,11 +38,9 @@ header select#sheetSel{font-weight:600;min-width:200px;max-width:320px}
|
|
|
38
38
|
.opts label em{margin-left:auto;color:var(--mut);font-style:normal;font-size:11px}
|
|
39
39
|
.opts .sw{width:12px;height:12px;border-radius:3px;flex:0 0 12px;border:1px solid var(--line)}
|
|
40
40
|
.opts label.textlayer{opacity:.55;cursor:default}
|
|
41
|
-
/* Fully custom checkbox
|
|
42
|
-
input[type=
|
|
43
|
-
input[type=radio]{border-radius:50%}
|
|
41
|
+
/* Fully custom checkbox — the native unchecked widget is a white/grey square that breaks the dark baseline. */
|
|
42
|
+
input[type=checkbox]{-webkit-appearance:none;appearance:none;width:14px;height:14px;flex:none;border:1px solid #475569;border-radius:3px;background:transparent;cursor:pointer;margin:0;vertical-align:middle}
|
|
44
43
|
input[type=checkbox]:checked{background:var(--brand);border-color:var(--brand);background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 10 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 4l3 3 5-5' stroke='%23fff' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center}
|
|
45
|
-
input[type=radio]:checked{background:var(--brand);border-color:var(--brand);box-shadow:inset 0 0 0 3px var(--panel)}
|
|
46
44
|
input:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
47
45
|
.facet.dim{opacity:.4} .facet.dim .opts,.facet.dim .acts{pointer-events:none}
|
|
48
46
|
.hint{color:var(--mut);font-size:11px;line-height:1.5}
|
|
@@ -82,12 +80,11 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
82
80
|
<div id="app">
|
|
83
81
|
<aside id="panel">
|
|
84
82
|
<section id="modes">
|
|
85
|
-
<h2>Match
|
|
86
|
-
<label><input type="
|
|
87
|
-
<label><input type="
|
|
88
|
-
<label><input type="
|
|
89
|
-
<
|
|
90
|
-
<label><input type="radio" name="mode" value="thickness+color" /> Thickness + Color</label>
|
|
83
|
+
<h2>Match on</h2>
|
|
84
|
+
<label><input type="checkbox" name="dim" value="layer" checked /> Layer</label>
|
|
85
|
+
<label><input type="checkbox" name="dim" value="thickness" /> Thickness</label>
|
|
86
|
+
<label><input type="checkbox" name="dim" value="color" /> Color</label>
|
|
87
|
+
<p class="hint">Checked criteria must all match (AND).</p>
|
|
91
88
|
</section>
|
|
92
89
|
<section class="facet" id="facet-layers">
|
|
93
90
|
<h2>Layers <span class="acts"><button data-all="layers">all</button><button data-none="layers">none</button></span></h2>
|
|
@@ -128,7 +125,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
128
125
|
</div>
|
|
129
126
|
|
|
130
127
|
<script type="module">
|
|
131
|
-
import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
128
|
+
import { matchesActive, modeDims, canonicalMode, selFromFilter, eyedropperAdd, countShown, bgRect, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
132
129
|
|
|
133
130
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
134
131
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
@@ -183,9 +180,11 @@ function showEmpty(msg){
|
|
|
183
180
|
}
|
|
184
181
|
|
|
185
182
|
function initFromFilter(){
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
// Canonicalize on load (drops unknown tokens, orders dims) so a facet-only save writes a valid
|
|
184
|
+
// enum string; a saved-empty '' round-trips as '', while an absent mode defaults to 'layer'.
|
|
185
|
+
mode = (F.mode == null) ? 'layer' : canonicalMode(modeDims(F.mode));
|
|
186
|
+
const on = new Set(modeDims(mode));
|
|
187
|
+
document.querySelectorAll('input[name=dim]').forEach(cb => { cb.checked = on.has(cb.value); });
|
|
189
188
|
// layer colours: steel layers vivid+distinct, others a single muted slate (GLOBAL facets)
|
|
190
189
|
let si = 0;
|
|
191
190
|
layerColor = {};
|
|
@@ -217,7 +216,16 @@ function setSheet(i){
|
|
|
217
216
|
const sh = sheets[active] || {};
|
|
218
217
|
const pg = (sh.page && sh.page.w > 0 && sh.page.h > 0) ? sh.page : { w: 1000, h: 1000 }; // guard empty page → no NaN viewBox
|
|
219
218
|
svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
|
|
220
|
-
if(pg.bg_b64){
|
|
219
|
+
if(pg.bg_b64){
|
|
220
|
+
// Register the raster to its true extent (the framing-area clip), NOT the whole page — else a
|
|
221
|
+
// crop gets stretched over the full viewBox and floats off-scale from the vector linework.
|
|
222
|
+
const r = bgRect(sh);
|
|
223
|
+
bgImg.setAttribute('href', 'data:image/jpeg;base64,' + pg.bg_b64);
|
|
224
|
+
bgImg.setAttribute('x', r.x); bgImg.setAttribute('y', r.y);
|
|
225
|
+
bgImg.setAttribute('width', r.w); bgImg.setAttribute('height', r.h);
|
|
226
|
+
bgImg.setAttribute('preserveAspectRatio', 'none');
|
|
227
|
+
bgImg.style.display='';
|
|
228
|
+
}
|
|
221
229
|
else bgImg.style.display = 'none';
|
|
222
230
|
buildOverlay();
|
|
223
231
|
base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base }; setVB();
|
|
@@ -309,13 +317,17 @@ function apply(){
|
|
|
309
317
|
html += ' · <b>' + sa + '</b> of <b>' + ta + '</b> total (' + sheets.length + ' sheets)';
|
|
310
318
|
}
|
|
311
319
|
countEl.innerHTML = html;
|
|
312
|
-
|
|
313
|
-
document.getElementById('facet-
|
|
314
|
-
document.getElementById('facet-
|
|
320
|
+
const dims = new Set(modeDims(mode));
|
|
321
|
+
document.getElementById('facet-layers').classList.toggle('dim', !dims.has('layer'));
|
|
322
|
+
document.getElementById('facet-thicknesses').classList.toggle('dim', !dims.has('thickness'));
|
|
323
|
+
document.getElementById('facet-colors').classList.toggle('dim', !dims.has('color'));
|
|
315
324
|
}
|
|
316
325
|
|
|
317
326
|
// --- modes + all/none ---
|
|
318
|
-
document.querySelectorAll('input[name=
|
|
327
|
+
document.querySelectorAll('input[name=dim]').forEach(cb => cb.addEventListener('change', () => {
|
|
328
|
+
mode = canonicalMode([...document.querySelectorAll('input[name=dim]:checked')].map(c => c.value));
|
|
329
|
+
markDirty(); apply();
|
|
330
|
+
}));
|
|
319
331
|
document.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.all, true)));
|
|
320
332
|
document.querySelectorAll('[data-none]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.none, false)));
|
|
321
333
|
function setAllFacet(facet, on){
|