@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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.58.0" : void 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.58.0" : void 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
- { bodyLimit: 25 * 1024 * 1024 },
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 mode." },
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.1.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 to the new image>"]`. (The user may also just
35
- ask in prose with an image attached — same procedure, skip the request lookup.)
36
- 2. **Read the new drawing.** Open the image at `snapshots[0]` (a real PNG/JPEG/WebP on disk).
37
- Read it with your own vision this is the compose-time extraction.
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.2.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>"]` (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
- ### 1b. Filter pre-stage isolate the steel linework FIRST (the `filter` node)
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); `steelBbox` = the bbox
67
- of this sheet's steel-layer elements (Zoom-to-steel).
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 tell the user to open the **Filter** node, flip through the sheets, narrow to the steel, and
78
- **Save**. The saved `on` flags + `mode` are what step 2 reads — **per sheet** (each sheet's elements
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) when present.** If the contract carries a saved
84
- > `filter`, the member candidate set **for each sheet** is that sheet's **kept** line elements
85
- > (`filter.sheets[i].elements` whose layer/thickness/colour pass the GLOBAL `mode` + `on` flags)
86
- > bind labels to *those*, not to a length-filtered scan of the whole sheet. The length heuristic
87
- > below is the **fallback** only when no filter was saved.
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 aggregate the
189
- editor's Confidence chip shows. `worklist` is the rfi/low/med members dragging the score, worst-first
190
- (heaviest first within a band), each with a `reason`. (Omit `contract` to score the stored one.)
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 drawing (PDF or image). Your terminal AI re-reads it and updates this workflow, then asks you to approve — nothing changes until you do.',
1392
- fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['pdf', 'png', 'jpg', 'webp'] }],
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 dataUrl = res[name];
1397
- const sourceName = res[name + '__filename'] || undefined;
1398
- if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
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: [{ dataUrl }] }),
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('Queued — your terminal AI will re-read it and update this workflow, then ask you to approve.', 'ok');
1408
- appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, update this workflow, and ask you to approve the new lock.`);
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
- const first = $body.querySelector('input:not(.fm-file-input),textarea');
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
- if (requiredChecks.length) {
2402
- const syncOkGate = () => { $ok.disabled = requiredChecks.some((el) => !el.checked); };
2403
- requiredChecks.forEach((el) => { el.onchange = syncOkGate; });
2404
- syncOkGate();
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 : 'thickness' | 'layer' | 'color' | 'thickness+layer' | 'thickness+color'
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
- /** The active match predicate does this element pass the current mode + selection? */
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 byLayer = () => sel.layers.has(el.layer);
21
- const byThk = () => sel.thicknesses.has(el.w);
22
- const byColor = () => sel.colors.has(el.color);
23
- switch (mode) {
24
- case 'thickness': return byThk();
25
- case 'layer': return byLayer();
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/radio — the native unchecked widget is a white/grey square that breaks the dark baseline. */
42
- input[type=radio],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}
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 mode</h2>
86
- <label><input type="radio" name="mode" value="layer" checked /> Layer only</label>
87
- <label><input type="radio" name="mode" value="thickness" /> Thickness only</label>
88
- <label><input type="radio" name="mode" value="color" /> Color only</label>
89
- <label><input type="radio" name="mode" value="thickness+layer" /> Thickness + Layer</label>
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
- mode = F.mode || 'layer';
187
- document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
188
- document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
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){ bgImg.setAttribute('href', 'data:image/jpeg;base64,' + pg.bg_b64); bgImg.setAttribute('width', pg.w); bgImg.setAttribute('height', pg.h); bgImg.style.display=''; }
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
- document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
313
- document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
314
- document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
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=mode]').forEach(r => r.addEventListener('change', () => { if(r.checked){ mode = r.value; markDirty(); apply(); } }));
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){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
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": {