@floless/app 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.46.0" : void 0,
52859
+ define: true ? "0.48.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.46.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.48.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // workflow-update.ts
@@ -53862,10 +53862,23 @@ function bakeContractIntoApp(sourcePath, contract) {
53862
53862
  });
53863
53863
  }
53864
53864
  const filter = baked.filter;
53865
- if (filter && typeof filter === "object" && filter.page && typeof filter.page === "object") {
53866
- const page = { ...filter.page };
53867
- delete page.bg_b64;
53868
- baked.filter = { ...filter, page };
53865
+ if (filter && typeof filter === "object") {
53866
+ const stripPage = (p) => {
53867
+ if (p && typeof p === "object") {
53868
+ const page = { ...p };
53869
+ delete page.bg_b64;
53870
+ return page;
53871
+ }
53872
+ return p;
53873
+ };
53874
+ const next = { ...filter };
53875
+ if (filter.page) next.page = stripPage(filter.page);
53876
+ if (Array.isArray(filter.sheets)) {
53877
+ next.sheets = filter.sheets.map(
53878
+ (s) => s && typeof s === "object" ? { ...s, page: stripPage(s.page) } : s
53879
+ );
53880
+ }
53881
+ baked.filter = next;
53869
53882
  }
53870
53883
  doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
53871
53884
  (0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
@@ -54296,9 +54309,54 @@ function rollup(members, countWeighted = false) {
54296
54309
  }
54297
54310
  return { score: den > 0 ? Math.round(num2 / den * 100) : null, tons, counts };
54298
54311
  }
54312
+ function bandEnd(end, rows) {
54313
+ const row = end.conn ? rows.get(end.conn) : void 0;
54314
+ const complete = !!(row && row.type && row.detail && row.targets && Object.keys(row.targets).length > 0);
54315
+ const hasNote = !!(end.note && end.note.trim() !== "");
54316
+ const evidence = hasNote || row?.source === "drawing";
54317
+ if (end.connVerified === true) return { band: "verified", reason: "" };
54318
+ if (!end.conn && !hasNote) return { band: "rfi", reason: "no connection assigned" };
54319
+ if (complete && evidence) return { band: "high", reason: "" };
54320
+ if (complete) return { band: "med", reason: "connection type assumed (no drawing note)" };
54321
+ if (hasNote && end.detail && end.detail.trim() !== "") return { band: "med", reason: "free-text note + detail (not a mapped library row)" };
54322
+ if (end.conn && !row) return { band: "low", reason: "references an unknown connection row" };
54323
+ return { band: "low", reason: row ? "library row has a type only (no detail/component id)" : "connection type only (no detail)" };
54324
+ }
54325
+ function scoreConnections(contractInput) {
54326
+ const contract = contractInput ?? {};
54327
+ const rows = /* @__PURE__ */ new Map();
54328
+ for (const c of contract.connections ?? []) if (c && c.id) rows.set(c.id, c);
54329
+ const byEnd = [];
54330
+ (contract.plans ?? []).forEach((plan, planIdx) => {
54331
+ for (const m of plan.members ?? []) {
54332
+ if (m.role === "column") {
54333
+ const c = m.col ?? {};
54334
+ const { band, reason } = bandEnd(c, rows);
54335
+ byEnd.push({ id: `${m.id}#col`, planIdx, sheet: plan.sheet ?? "", memberId: m.id, which: "col", band, reason });
54336
+ } else {
54337
+ const ends = m.ends ?? [];
54338
+ for (const which of [0, 1]) {
54339
+ const { band, reason } = bandEnd(ends[which] ?? {}, rows);
54340
+ byEnd.push({ id: `${m.id}#${which}`, planIdx, sheet: plan.sheet ?? "", memberId: m.id, which, band, reason });
54341
+ }
54342
+ }
54343
+ }
54344
+ });
54345
+ const counts = emptyCounts();
54346
+ let num2 = 0, den = 0;
54347
+ for (const e of byEnd) {
54348
+ counts[e.band]++;
54349
+ if (e.band === "rfi") continue;
54350
+ num2 += BAND_WEIGHT[e.band];
54351
+ den += 1;
54352
+ }
54353
+ const category = { score: den > 0 ? Math.round(num2 / den * 100) : null, tons: 0, counts };
54354
+ return { byEnd, category };
54355
+ }
54299
54356
  function scoreContract(contractInput) {
54300
54357
  const contract = contractInput ?? {};
54301
54358
  const plans = contract.plans ?? [];
54359
+ const conn = scoreConnections(contract);
54302
54360
  const byMember = [];
54303
54361
  const byDetail = [];
54304
54362
  let totalSegments = 0;
@@ -54344,11 +54402,12 @@ function scoreContract(contractInput) {
54344
54402
  return {
54345
54403
  byMember,
54346
54404
  byDetail,
54405
+ byConnection: conn.byEnd,
54347
54406
  byCategory: {
54348
54407
  beams,
54349
54408
  columns,
54350
54409
  details,
54351
- connections: { score: null, note: "Not scored yet (reserved slot)" }
54410
+ connections: conn.category
54352
54411
  },
54353
54412
  coverage: {
54354
54413
  members: byMember.length,
@@ -37,7 +37,8 @@
37
37
  "additionalProperties": false
38
38
  },
39
39
  "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } },
40
- "filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage dataset from one compose-time PyMuPDF pass. The saved selection (per-facet `on` flags + `mode`) scopes which lines the reader binds profile labels to; replaces the length heuristic, fallback when absent. CONFIDENTIAL: `page.bg_b64` is machine-local, never committed." },
40
+ "filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage. `sheets[]` holds per-framing-plan geometry; the facets + `mode` + on-flag selection are GLOBAL across sheets. The saved selection scopes which lines the reader binds profile labels to per sheet; replaces the length heuristic, fallback when absent. CONFIDENTIAL: each sheet's `page.bg_b64` is machine-local, never committed." },
41
+ "connections": { "type": "array", "items": { "$ref": "#/$defs/connection" }, "description": "Vendor-neutral connection library: each row maps a connection type → the firm's design detail# → per-platform component ids. Member ends/columns reference a row by id via `ends[].conn` / `col.conn` (legacy `note`/`detail` = fallback when `conn` is empty)." },
41
42
  "dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
42
43
  },
43
44
  "$defs": {
@@ -46,10 +47,16 @@
46
47
  "additionalProperties": true,
47
48
  "description": "Steel filter pre-stage. Lines render as SVG paths over a dimmed page raster; text spans make profile labels pickable (and feed the label-binding step).",
48
49
  "properties": {
50
+ "active": { "type": "integer", "description": "Index into `sheets` of the active framing plan (mirrors the top-level plans/active)." },
51
+ "sheets": {
52
+ "type": "array",
53
+ "items": { "$ref": "#/$defs/filterSheet" },
54
+ "description": "Per-sheet filter GEOMETRY — one entry per confirmed framing plan. The facets (layers/thicknesses/colors), `mode` and on-flag selection are GLOBAL (top-level here) and apply to every sheet."
55
+ },
49
56
  "page": {
50
57
  "type": "object",
51
58
  "required": ["w", "h"],
52
- "description": "When present, w and h are required and positive the view sets the SVG viewBox from them (an empty page would yield viewBox=\"NaN …\").",
59
+ "description": "LEGACY pre-multisheet single-page geometry (now per-sheet in `sheets[]`). normalizeFilter wraps it into one sheet on load; the view migrates it into `sheets` on Save. When present, w and h are required and positive.",
53
60
  "properties": {
54
61
  "w": { "type": "number", "minimum": 1 },
55
62
  "h": { "type": "number", "minimum": 1 },
@@ -120,6 +127,57 @@
120
127
  }
121
128
  }
122
129
  },
130
+ "filterSheet": {
131
+ "type": "object",
132
+ "required": ["elements"],
133
+ "additionalProperties": true,
134
+ "description": "One framing plan's filter geometry. The dimmed `page.bg_b64` raster is CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed.",
135
+ "properties": {
136
+ "sheet": { "type": "string", "description": "Sheet number, e.g. S-201 — the switcher label." },
137
+ "title": { "type": "string" },
138
+ "page": {
139
+ "type": "object",
140
+ "required": ["w", "h"],
141
+ "properties": {
142
+ "w": { "type": "number", "minimum": 1 },
143
+ "h": { "type": "number", "minimum": 1 },
144
+ "bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." }
145
+ }
146
+ },
147
+ "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." },
148
+ "elements": {
149
+ "type": "array",
150
+ "description": "This sheet's drawing elements: lines (SVG path `d`) and text spans (`text`+`bbox`), each tagged layer/thickness/colour.",
151
+ "items": {
152
+ "type": "object",
153
+ "additionalProperties": true,
154
+ "properties": {
155
+ "kind": { "enum": ["line", "text"] },
156
+ "layer": { "type": "string" },
157
+ "w": { "type": "number" },
158
+ "color": { "type": "string" },
159
+ "d": { "type": "string" },
160
+ "text": { "type": "string" },
161
+ "bbox": { "type": "array", "items": { "type": "number" } },
162
+ "dashed": { "type": "boolean" }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ },
168
+ "connection": {
169
+ "type": "object",
170
+ "required": ["id", "type"],
171
+ "additionalProperties": true,
172
+ "description": "One connection-library row. `detail` is the firm's VENDOR-NEUTRAL design detail#; `targets` is an open map platform→component id (tekla/sds2/revit/…), never hardcoded to one platform.",
173
+ "properties": {
174
+ "id": { "type": "string", "description": "Stable row id, e.g. cx-moment-1." },
175
+ "type": { "type": "string", "description": "moment | drag | shear | braced | pinned | fixed (free string allowed)." },
176
+ "detail": { "type": "string", "description": "Design detail number, e.g. 50S504 — the firm's document, platform-neutral." },
177
+ "targets": { "type": "object", "additionalProperties": { "type": "string" }, "description": "platform → component id, e.g. { \"tekla\": \"146\" }. Open map; add sds2/revit freely." },
178
+ "source": { "enum": ["company-rules", "user", "drawing"], "description": "Where the row came from (drives the connection confidence band)." }
179
+ }
180
+ },
123
181
  "point2": {
124
182
  "type": "array",
125
183
  "items": { "type": "number" },
@@ -269,7 +327,9 @@
269
327
  "tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches at this end." },
270
328
  "note": { "type": "string", "description": "Connection note (e.g. moment, shear, braced)." },
271
329
  "tosDef": { "type": "boolean", "description": "True = follows the plan default; false = read from a drawing callout or set by hand." },
272
- "detail": { "type": "string", "description": "Connection detail reference, e.g. \"5-S504\"." }
330
+ "detail": { "type": "string", "description": "Connection detail reference, e.g. \"5-S504\"." },
331
+ "conn": { "type": "string", "description": "Connection-library row id this end uses (references `connections[].id`). Empty = fall back to `note`/`detail`." },
332
+ "connVerified": { "type": "boolean", "description": "True = a human confirmed this end's connection (scores Verified)." }
273
333
  }
274
334
  },
275
335
  "col": {
@@ -279,7 +339,9 @@
279
339
  "bos": { "type": ["number", "null"], "description": "Bottom-of-steel elevation in inches." },
280
340
  "tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches." },
281
341
  "note": { "type": "string" },
282
- "detail": { "type": "string" }
342
+ "detail": { "type": "string" },
343
+ "conn": { "type": "string", "description": "Connection-library row id for this column's connection (references `connections[].id`)." },
344
+ "connVerified": { "type": "boolean", "description": "True = a human confirmed this column's connection (scores Verified)." }
283
345
  }
284
346
  }
285
347
  }
@@ -41,45 +41,50 @@ and proceed from that path.
41
41
 
42
42
  ### 1b. Filter pre-stage — isolate the steel linework FIRST (the `filter` node)
43
43
 
44
- Before the heavy read, do **one** deterministic PyMuPDF pass that enumerates *every* drawing
45
- element by **layer · thickness · colour** and bakes it into the contract's `filter` block. The user
46
- then opens the **Filter** node (double-click → the steel-filter view) and keeps only the real steel
47
- lines — by facet (layer/thickness/colour) or the **eyedropper** (click a line or label → Add its
48
- layer/thickness/colour). The saved selection is what step 2 binds profile labels to, so a clean
49
- framing plan goes in instead of the whole noisy sheet. This is a big reliability win
50
- (`S-ANNO-BEAM-STEL` + thick line beats a length-only guess) **and** makes the profile-label search in
51
- step 2 far cleaner (fewer candidate lines).
52
-
53
- One pass, display space (`page.rotation_matrix` same as step 2, never mix native/display coords):
54
-
55
- 1. **Lines** `page.get_drawings()`: for each path keep `{ kind:"line", layer:<OCG name>, w:<stroke
56
- width pt>, color:<#rrggbb stroke>, d:<SVG path built from the path items>, dashed:<bool> }`. The
57
- **layer** is the path's optional-content group (OCG) name; thickness is the stroke width; colour
58
- the stroke colour.
59
- 2. **Text** — `page.get_text("dict")` (or rawdict): for each span keep `{ kind:"text", layer:<OCG>,
60
- w:0, color:<#hex>, text:<string>, bbox:[x0,y0,x1,y1] }`. Text elements make profile **labels**
61
- pickable (the eyedropper) and are the candidate set step 2's label-binding iterates.
62
- 3. **Facets** — aggregate distinct `layers` / `thicknesses` / `colors` with a `count` each. Round
63
- each element's `w` and the facet `w` the **same** way — matching is exact-equality, so a rounding
64
- mismatch would silently hide lines. Mark
65
- `steel: true` on layers whose name reads as steel framing (e.g. `*BEAM*STEL*`, `*COL*`, `*BRACE*`,
66
- `*JOIST*`) → those default **on**; everything else defaults off. Mark `drawable: false` on pure
67
- text/dimension/title-block layers (listed for completeness, not drawn as lines).
68
- 4. **Page + bbox** — `page: { w, h, bg_b64 }` where `bg_b64` is a dimmed raster of the framing area
44
+ Before the heavy read, do **one** deterministic PyMuPDF pass **per framing-plan sheet** that enumerates
45
+ *every* drawing element by **layer · thickness · colour** and bakes it into the contract's `filter`
46
+ block. The user then opens the **Filter** node (double-click → the steel-filter view), flips through
47
+ the sheets, and keeps only the real steel lines — by facet (layer/thickness/colour) or the
48
+ **eyedropper**. Because a coordinated drawing set uses one CAD template, the facet selection is
49
+ **global**: narrow once, it applies to every sheet. The saved selection is what step 2 binds profile
50
+ labels to, per sheet.
51
+
52
+ **First, pick the plans (confirm gate).** From the sheet index (step 2.1) enumerate the framing-plan
53
+ sheets (Foundation, each Floor, Roof). **Show the user the list and get an explicit OK** —
54
+ "I found these framing plans: S-201, S-202, S-301 — filter all of these?" — before the pass. (The full
55
+ elevation/detail classification is the broader workflow; here we only need the plan set.)
56
+
57
+ Then, for **each** confirmed plan sheet, one pass in display space (`page.rotation_matrix` never mix
58
+ native/display coords):
59
+
60
+ 1. **Lines** `page.get_drawings()`: per path keep `{ kind:"line", layer:<OCG name>, w:<stroke width
61
+ pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
62
+ 2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
63
+ text:<string>, bbox:[x0,y0,x1,y1] }`.
64
+ 3. **Per-sheet entry** push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64 }, steelBbox,
65
+ elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
69
66
  (**title block excluded** — CONFIDENTIAL, machine-local, never committed); `steelBbox` = the bbox
70
- of the steel-layer elements (for Zoom-to-steel). Set `mode: "layer"`.
71
- 5. **PUT** the contract with this `filter` block (`PUT /api/contract/steel-takeoff`). On a re-read,
72
- preserve any existing per-facet `on` flags the user already set.
73
-
74
- Then tell the user to open the **Filter** node, narrow to the steel, and **Save**. The saved `on`
75
- flags + `mode` are what step 2 reads.
67
+ of this sheet's steel-layer elements (Zoom-to-steel).
68
+ 4. **Global facets (union across sheets)** aggregate distinct `layers` / `thicknesses` / `colors`
69
+ with a summed `count` each, ACROSS all sheets, into the top-level `filter.layers/thicknesses/colors`
70
+ (NOT per-sheet). Round each element's `w` and the facet `w` the **same** way (matching is
71
+ exact-equality). Mark `steel: true` on layers whose name reads as steel framing (e.g.
72
+ `*BEAM*STEL*`, `*COL*`, `*BRACE*`, `*JOIST*`) default **on**; others off. Mark
73
+ `drawable: false` on pure text/dimension/title-block layers.
74
+ 5. Set `filter.mode: "layer"`, `filter.active: 0`. **PUT** the contract
75
+ (`PUT /api/contract/steel-takeoff`). On a re-read, preserve any existing per-facet `on` flags.
76
+
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).
76
80
 
77
81
  ### 2. Read it with your own vision — the heavy pipeline
78
82
 
79
83
  > **Candidate geometry comes from the filter (1b) when present.** If the contract carries a saved
80
- > `filter`, the member candidate set is the **kept** line elements (those whose layer/thickness/colour
81
- > pass `mode` + the `on` flags) — bind labels to *those*, not to a length-filtered scan of the whole
82
- > sheet. The length heuristic below is the **fallback** only when no filter was 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.
83
88
 
84
89
  Follow the methodology condensed here (full detail + every gotcha in
85
90
  `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
package/dist/web/aware.js CHANGED
@@ -1471,7 +1471,19 @@
1471
1471
  $contractEditor.hidden = false;
1472
1472
  return true;
1473
1473
  }
1474
- function closeContractEditor() {
1474
+ async function closeContractEditor() {
1475
+ // A surface inside the iframe (the steel filter) can expose a same-origin dirty-guard so we show
1476
+ // OUR styled confirm modal instead of its native beforeunload "Leave site?" (#170). Cross-origin
1477
+ // (about:blank) access throws — treat that as "no guard".
1478
+ let guard = null;
1479
+ try { guard = $contractEditorFrame.contentWindow && $contractEditorFrame.contentWindow.__floGuard; }
1480
+ catch (_) { guard = null; }
1481
+ if (guard && typeof guard.isDirty === 'function' && guard.isDirty()) {
1482
+ const choice = await confirmUnsavedSwitch('', 'You have unsaved changes to the steel filter. What would you like to do?', 'Unsaved filter changes');
1483
+ if (choice === 'cancel') return; // keep the view open
1484
+ if (choice === 'save') { const ok = await guard.save(); if (!ok) return; } // save failed → stay open
1485
+ else if (choice === 'discard') { try { guard.discard(); } catch (_) {} } // silence the backstop
1486
+ }
1475
1487
  $contractEditor.hidden = true;
1476
1488
  $contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
1477
1489
  }
@@ -1849,13 +1861,15 @@
1849
1861
  // Save is the primary (Enter); Esc and the backdrop both cancel (the safe,
1850
1862
  // non-destructive default — never lose edits to a stray key/click).
1851
1863
  const $confirmModal = document.getElementById('confirm-modal');
1852
- function confirmUnsavedSwitch(appName) {
1864
+ function confirmUnsavedSwitch(appName, subMsg, titleMsg) {
1853
1865
  return new Promise((resolve) => {
1866
+ const $title = document.getElementById('confirm-modal-title');
1854
1867
  const $sub = document.getElementById('confirm-modal-sub');
1855
1868
  const $save = document.getElementById('confirm-save');
1856
1869
  const $dont = document.getElementById('confirm-dont-save');
1857
1870
  const $cancel = document.getElementById('confirm-cancel');
1858
- $sub.textContent = `You changed the inputs for “${appName}” but haven’t saved them yet. What would you like to do?`;
1871
+ $title.textContent = titleMsg || 'Unsaved input changes'; // reset each call (default = the inputs-switch copy)
1872
+ $sub.textContent = subMsg || `You changed the inputs for “${appName}” but haven’t saved them yet. What would you like to do?`;
1859
1873
  showModal($confirmModal);
1860
1874
  setTimeout(() => $save.focus(), 0);
1861
1875
  const done = (result) => {
@@ -426,7 +426,7 @@
426
426
  input changes. Reuses the shared modal styling; no new backdrop look. -->
427
427
  <div class="modal-backdrop" id="confirm-modal">
428
428
  <div class="modal">
429
- <div class="modal-title">Unsaved input changes</div>
429
+ <div class="modal-title" id="confirm-modal-title">Unsaved input changes</div>
430
430
  <div class="modal-sub" id="confirm-modal-sub"></div>
431
431
  <div class="modal-actions">
432
432
  <button id="confirm-dont-save">Don’t save</button>
@@ -891,7 +891,7 @@ function panel(){
891
891
  else if(allBeam){const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
892
892
  wMTos('tosA',s0);wMText('ntA',s0,(o,v)=>o.note=v);wMText('dtA',s0,(o,v)=>o.detail=v);
893
893
  wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
894
- {const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});}}
894
+ {const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});}}
895
895
  return;}
896
896
  const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
897
897
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
@@ -951,7 +951,7 @@ function panel(){
951
951
  wireTos('tosB',m.ends[1]);
952
952
  document.getElementById('ntB').onchange=e=>edit(()=>{m.ends[1].note=e.target.value;});
953
953
  wireDet('dtB',m.ends[1]);
954
- document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});
954
+ document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});
955
955
  }
956
956
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // also drop any dims a mixed marquee selected
957
957
  {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
@@ -1527,7 +1527,29 @@ function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft
1527
1527
  return ft*plf/2000;}
1528
1528
  function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
1529
1529
  const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
1530
+ function _scoreConnections(){
1531
+ const rows={};for(const c of (C.connections||[]))if(c&&c.id)rows[c.id]=c;
1532
+ const bandEnd=(end)=>{const row=end.conn?rows[end.conn]:null;
1533
+ const complete=!!(row&&row.type&&row.detail&&row.targets&&Object.keys(row.targets).length>0);
1534
+ const hasNote=!!(end.note&&end.note.trim()!=='');
1535
+ // Only a drawing callout counts as evidence — a company-rules/user row assignment is a mapping, not drawing-confirmed.
1536
+ const evidence=hasNote||(row&&row.source==='drawing');
1537
+ if(end.connVerified===true)return{band:'verified',reason:''};
1538
+ if(!end.conn&&!hasNote)return{band:'rfi',reason:'no connection assigned'};
1539
+ if(complete&&evidence)return{band:'high',reason:''};
1540
+ if(complete)return{band:'med',reason:'connection type assumed (no drawing note)'};
1541
+ if(hasNote&&end.detail&&end.detail.trim()!=='')return{band:'med',reason:'free-text note + detail (not a mapped library row)'};
1542
+ if(end.conn&&!row)return{band:'low',reason:'references an unknown connection row'};
1543
+ return{band:'low',reason:row?'library row has a type only (no detail/component id)':'connection type only (no detail)'};};
1544
+ const byEnd=[];
1545
+ (C.plans||[]).forEach((plan,pi)=>{for(const m of (plan.members||[])){
1546
+ if(m.role==='column'){const r=bandEnd(m.col||{});byEnd.push({id:m.id+'#col',pi,sheet:plan.sheet||'',memberId:m.id,which:'col',band:r.band,reason:r.reason});}
1547
+ else{const ends=m.ends||[];for(const w of [0,1]){const r=bandEnd(ends[w]||{});byEnd.push({id:m.id+'#'+w,pi,sheet:plan.sheet||'',memberId:m.id,which:w,band:r.band,reason:r.reason});}}}});
1548
+ const counts=_cnt0();let num=0,den=0;
1549
+ for(const e of byEnd){counts[e.band]++;if(e.band==='rfi')continue;num+=BANDW[e.band];den+=1;}
1550
+ return{cat:{score:den>0?Math.round(num/den*100):null,tons:0,counts},ends:byEnd};}
1530
1551
  function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[];let segs=0;
1552
+ const _conn=_scoreConnections();
1531
1553
  const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
1532
1554
  plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
1533
1555
  for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
@@ -1536,7 +1558,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
1536
1558
  const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
1537
1559
  const beams=roll(byMember.filter(m=>m.role==='beam'));const columns=roll(byMember.filter(m=>m.role==='column'));const details=dRoll(byDetail);
1538
1560
  const sc=byMember.filter(m=>m.band!=='rfi');const n=sc.reduce((s,m)=>s+m.tons*BANDW[m.band],0),d=sc.reduce((s,m)=>s+m.tons,0);
1539
- return {byMember,byDetail,byCategory:{beams,columns,details,connections:{score:null,note:'Not scored yet (reserved slot)',counts:_cnt0()}},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
1561
+ return {byMember,byDetail,byConnection:_conn.ends,byCategory:{beams,columns,details,connections:_conn.cat},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
1540
1562
  function bandColorForPct(p){return p==null?'var(--mut)':p>=95?BANDC.verified:p>=80?BANDC.high:p>=50?BANDC.med:BANDC.low;}
1541
1563
  // Effective target: the user's per-read override (in the contract) wins; else the app's
1542
1564
  // target_confidence input default (read in boot()); else none. Editing the override never touches
@@ -1569,7 +1591,7 @@ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
1569
1591
  '<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
1570
1592
  _bar(stub?null:cs.score)+
1571
1593
  '<div class=cc-counts>'+(stub?'<span>reserved slot</span>':_countsHtml(cs.counts))+'</div></div>';
1572
- document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections,true);
1594
+ document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections);
1573
1595
  const bands=['all','verified','high','med','low','rfi'],cats=['all','beams','columns','details','connections'];
1574
1596
  document.getElementById('confFilter').innerHTML=
1575
1597
  '<span class=glab>Band</span><span class=grp>'+bands.map(b=>'<button data-band="'+b+'" class="'+(confBand===b?'on':'')+'">'+(b==='all'?'All':BANDLAB[b])+'</button>').join('')+'</span>'+
@@ -1579,7 +1601,21 @@ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
1579
1601
  wireConf();}
1580
1602
  function confBodyHtml(s){
1581
1603
  if(!s.byMember.length&&!s.byDetail.length)return '<div class=hint style="padding:14px 0">No members in the takeoff yet.</div>';
1582
- if(confCat==='connections')return '<div class=hint style="padding:14px 0">Connection scoring is not built yet (reserved slot).</div>';
1604
+ if(confCat==='connections'){
1605
+ // worst-first so the RFIs / unmapped ends surface at the top (the "what to fix next" view)
1606
+ const _ord={rfi:0,low:1,med:2,high:3,verified:4};
1607
+ let conn=s.byConnection.slice().sort((a,b)=>_ord[a.band]-_ord[b.band]);
1608
+ if(confBand!=='all')conn=conn.filter(e=>e.band===confBand);
1609
+ if(!conn.length)return '<div class=hint style="padding:14px 0">Nothing matches this filter.</div>';
1610
+ const whichLabel=w=>w==='col'?'Column':w===0?'End 1':'End 2';
1611
+ let h='<table class=ftab><thead><tr><th>Band</th><th>Member</th><th>End</th><th>Sheet</th><th>Reason</th><th></th></tr></thead><tbody>';
1612
+ conn.forEach(e=>{h+='<tr><td><span class=pill style="'+BANDPILL[e.band]+'">'+BANDLAB[e.band]+'</span></td>'+
1613
+ '<td>'+esc(e.memberId)+'</td>'+
1614
+ '<td>'+whichLabel(e.which)+'</td>'+
1615
+ '<td>'+esc(e.sheet)+'</td>'+
1616
+ '<td><span class=hint>'+esc(e.reason||'—')+'</span></td>'+
1617
+ '<td><button class=ghost data-loc="'+esc(e.memberId)+'" data-pi="'+e.pi+'">Locate</button></td></tr>';});
1618
+ h+='</tbody></table>';return h;}
1583
1619
  let mem=s.byMember.slice();
1584
1620
  if(confCat==='beams')mem=mem.filter(m=>m.role==='beam');else if(confCat==='columns')mem=mem.filter(m=>m.role==='column');else if(confCat==='details')mem=[];
1585
1621
  if(confBand!=='all')mem=mem.filter(m=>m.band===confBand);
@@ -77,3 +77,35 @@ export function applySelToFilter(filter, sel, mode) {
77
77
  for (const c of filter.colors || []) c.on = sel.colors.has(c.hex);
78
78
  return filter;
79
79
  }
80
+
81
+ /**
82
+ * Multi-sheet normalizer. The shipped single-sheet filter carries `page`/`steelBbox`/`elements`
83
+ * at the top level; the multi-sheet shape carries `sheets:[{sheet,title?,page,steelBbox,elements}]`
84
+ * + `active`. This returns `{ sheets, active }` for both, wrapping a legacy filter into one sheet so
85
+ * the view has a single code path. Facets (layers/thicknesses/colors) are GLOBAL and stay on the
86
+ * filter object — they are not touched here.
87
+ *
88
+ * ponytail: back-compat bridge for pre-multisheet contracts. Upgrade path: drop the legacy branch
89
+ * once no single-sheet contracts remain in the wild (the view migrates them forward on Save).
90
+ */
91
+ export function normalizeFilter(filter) {
92
+ if (!filter || typeof filter !== 'object') return { sheets: [], active: 0 };
93
+ let sheets;
94
+ if (Array.isArray(filter.sheets)) {
95
+ sheets = filter.sheets;
96
+ } else if (Array.isArray(filter.elements) && filter.elements.length) {
97
+ sheets = [{
98
+ sheet: filter.sheet || '',
99
+ title: filter.title,
100
+ page: filter.page,
101
+ steelBbox: filter.steelBbox,
102
+ elements: filter.elements,
103
+ }];
104
+ } else {
105
+ sheets = [];
106
+ }
107
+ const n = sheets.length;
108
+ const a = Number.isInteger(filter.active) ? filter.active : 0;
109
+ const active = (n === 0 || a < 0 || a >= n) ? 0 : a;
110
+ return { sheets, active };
111
+ }
@@ -24,6 +24,9 @@ button{background:#334155;color:var(--text);border:1px solid #475569;border-radi
24
24
  button:hover{background:#475569} button:disabled{opacity:.5;cursor:default}
25
25
  button.primary{background:var(--brand);border-color:var(--brand);color:#fff} button.primary:hover{background:#2f6fe0}
26
26
  button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
27
+ select{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 8px;font:13px system-ui;cursor:pointer}
28
+ select:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
29
+ header select#sheetSel{font-weight:600;min-width:200px;max-width:320px}
27
30
  #app{display:flex;flex:1;min-height:0}
28
31
  #panel{width:300px;flex:0 0 300px;background:var(--panel);border-right:1px solid var(--line);padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:18px}
29
32
  #panel h2{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 8px;display:flex;align-items:center;justify-content:space-between}
@@ -70,6 +73,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
70
73
  <body>
71
74
  <header>
72
75
  <b>Steel filter</b>
76
+ <select id="sheetSel" title="Switch framing-plan sheet" style="display:none"></select>
73
77
  <span id="src" class="stat"></span>
74
78
  <span class="sp"></span>
75
79
  <span id="saved" class="stat"></span>
@@ -124,7 +128,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
124
128
  </div>
125
129
 
126
130
  <script type="module">
127
- import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter } from './steel-filter-core.js';
131
+ import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
128
132
 
129
133
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
130
134
  const SVGNS = 'http://www.w3.org/2000/svg';
@@ -147,6 +151,8 @@ let mode = 'layer';
147
151
  let nodes = []; // [{ node, el }]
148
152
  let layerColor = {};
149
153
  let dirty = false;
154
+ let sheets = []; // normalizeFilter(F).sheets — per-sheet geometry
155
+ let active = 0; // index into sheets
150
156
 
151
157
  function setSaved(state){
152
158
  savedEl.className = 'stat ' + (state || '');
@@ -162,7 +168,9 @@ async function boot(){
162
168
  if(!res.ok){ showEmpty(); return; }
163
169
  C = await res.json();
164
170
  F = C && C.filter;
165
- if(!F || !Array.isArray(F.elements) || !F.elements.length){ showEmpty(); return; }
171
+ const norm = normalizeFilter(F);
172
+ sheets = norm.sheets; active = norm.active;
173
+ if(!F || !sheets.some(s => Array.isArray(s.elements) && s.elements.length)){ showEmpty(); return; }
166
174
  document.getElementById('empty').classList.remove('show');
167
175
  document.getElementById('src').textContent = (C.source && C.source.name) ? ('· ' + C.source.name) : '';
168
176
  initFromFilter();
@@ -178,27 +186,49 @@ function initFromFilter(){
178
186
  mode = F.mode || 'layer';
179
187
  document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
180
188
  document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
181
- // layer colours: steel layers vivid+distinct, others a single muted slate
189
+ // layer colours: steel layers vivid+distinct, others a single muted slate (GLOBAL facets)
182
190
  let si = 0;
183
191
  layerColor = {};
184
192
  for(const l of F.layers || []) layerColor[l.name] = l.steel ? PALETTE[si++ % PALETTE.length] : '#94a3b8';
185
193
  sel = selFromFilter(F);
186
- // page + viewBox
187
- const pg = (F.page && F.page.w > 0 && F.page.h > 0) ? F.page : { w: 1000, h: 1000 }; // guard an empty page no NaN viewBox
194
+ // Facets are GLOBAL — one selection for every sheet. The producer (skill step 1b) MUST aggregate
195
+ // the UNION of layers/thicknesses/colors across all sheets here; a facet value present on a sheet
196
+ // but missing from these lists has no toggle, so its elements would silently stay hidden.
197
+ buildFacet('layers', F.layers || [], 'name', it => it.name, it => it.name);
198
+ buildFacet('thicknesses', F.thicknesses || [], 'w', it => it.w + ' pt', it => it.w);
199
+ buildFacet('colors', F.colors || [], 'hex', it => it.hex, it => it.hex);
200
+ // sheet switcher (mirror the editor's planSel) — hidden for a single-sheet set
201
+ const sheetSel = document.getElementById('sheetSel');
202
+ sheetSel.innerHTML = sheets.map((s, i) => {
203
+ const lbl = (s.sheet || ('Sheet ' + (i + 1))) + (s.title ? (' · ' + s.title) : '');
204
+ return `<option value="${i}">${esc(lbl)}</option>`;
205
+ }).join('');
206
+ sheetSel.style.display = sheets.length > 1 ? '' : 'none';
207
+ sheetSel.onchange = e => setSheet(+e.target.value);
208
+ setSheet(active);
209
+ }
210
+
211
+ // Render one framing-plan sheet; facets/sel/mode are global and persist across sheets.
212
+ // Switching sheets is NAVIGATION, not a document edit — it deliberately does not markDirty() (an
213
+ // estimator pages through framing plans constantly; a false "unsaved changes" prompt on read-only
214
+ // browsing is worse than not persisting the viewed-sheet index). `active` is written on the next Save.
215
+ function setSheet(i){
216
+ active = Math.min(Math.max(0, i|0), sheets.length - 1);
217
+ const sh = sheets[active] || {};
218
+ const pg = (sh.page && sh.page.w > 0 && sh.page.h > 0) ? sh.page : { w: 1000, h: 1000 }; // guard empty page → no NaN viewBox
188
219
  svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
189
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=''; }
190
221
  else bgImg.style.display = 'none';
191
222
  buildOverlay();
192
- buildFacet('layers', F.layers || [], 'name', it => it.name, it => it.name);
193
- buildFacet('thicknesses', F.thicknesses || [], 'w', it => it.w + ' pt', it => it.w);
194
- buildFacet('colors', F.colors || [], 'hex', it => it.hex, it => it.hex);
195
- base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base };
223
+ base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base }; setVB();
196
224
  apply();
225
+ closeEye();
226
+ const ss = document.getElementById('sheetSel'); if(ss) ss.value = String(active);
197
227
  }
198
228
 
199
229
  function buildOverlay(){
200
230
  overlay.replaceChildren();
201
- nodes = (F.elements || []).map(el => {
231
+ nodes = ((sheets[active] && sheets[active].elements) || []).map(el => {
202
232
  let n;
203
233
  if(el.kind === 'text'){
204
234
  n = document.createElementNS(SVGNS, 'text');
@@ -271,7 +301,14 @@ function apply(){
271
301
  const vis = matchesActive(el, mode, sel);
272
302
  node.style.display = vis ? '' : 'none';
273
303
  }
274
- countEl.innerHTML = '<b>' + countShown(F.elements, mode, sel) + '</b> of <b>' + nodes.length + '</b> elements shown';
304
+ const cur = (sheets[active] && sheets[active].elements) || [];
305
+ let html = '<b>' + countShown(cur, mode, sel) + '</b> of <b>' + cur.length + '</b> shown';
306
+ if(sheets.length > 1){
307
+ let sa = 0, ta = 0;
308
+ for(const s of sheets){ const els = s.elements || []; sa += countShown(els, mode, sel); ta += els.length; }
309
+ html += ' · <b>' + sa + '</b> of <b>' + ta + '</b> total (' + sheets.length + ' sheets)';
310
+ }
311
+ countEl.innerHTML = html;
275
312
  document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
276
313
  document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
277
314
  document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
@@ -345,7 +382,7 @@ svg.addEventListener('pointerup', e => {
345
382
  drag = null;
346
383
  });
347
384
  function fit(){ vb = { ...base }; setVB(); }
348
- function zoomSteel(){ const b = F && F.steelBbox; if(!b){ fit(); return; } const pad = 40; vb = { x: b[0]-pad, y: b[1]-pad, w: (b[2]-b[0])+2*pad, h: (b[3]-b[1])+2*pad }; setVB(); }
385
+ function zoomSteel(){ const b = sheets[active] && sheets[active].steelBbox; if(!b){ fit(); return; } const pad = 40; vb = { x: b[0]-pad, y: b[1]-pad, w: (b[2]-b[0])+2*pad, h: (b[3]-b[1])+2*pad }; setVB(); }
349
386
  document.getElementById('fit').onclick = fit;
350
387
  document.getElementById('fit2').onclick = fit;
351
388
  document.getElementById('zoomsteel').onclick = zoomSteel;
@@ -355,7 +392,9 @@ document.getElementById('zoomsteel2').onclick = zoomSteel;
355
392
  saveBtn.onclick = save;
356
393
  async function save(){
357
394
  if(!C || !F) return;
358
- applySelToFilter(F, sel, mode);
395
+ applySelToFilter(F, sel, mode); // global facet on-flags + mode
396
+ F.sheets = sheets; F.active = active;
397
+ delete F.page; delete F.elements; delete F.steelBbox; // migrate legacy single-sheet shape forward
359
398
  C.filter = F;
360
399
  saveBtn.disabled = true; setSaved('');
361
400
  try {
@@ -365,6 +404,15 @@ async function save(){
365
404
  } catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
366
405
  }
367
406
  window.addEventListener('beforeunload', e => { if(dirty){ e.preventDefault(); e.returnValue = ''; } });
407
+ // #170: same-origin bridge so the parent (aware.js) can show ITS styled confirm modal on close
408
+ // instead of the iframe's native beforeunload "Leave site?". beforeunload above stays only as the
409
+ // hard tab/browser-close backstop. save() resolves dirty→false on success; discard() silences the
410
+ // backstop so an intentional close doesn't re-trigger the native prompt.
411
+ window.__floGuard = {
412
+ isDirty: () => dirty,
413
+ save: async () => { await save(); return !dirty; },
414
+ discard: () => { dirty = false; },
415
+ };
368
416
 
369
417
  // Test seam (mirrors steel-3d-view's debug()/probe()) — lets a headless E2E drive the eyedropper
370
418
  // and inspect state deterministically without fragile sub-pixel clicks on thin SVG strokes.
@@ -372,8 +420,11 @@ window.__filterTest = {
372
420
  ready: () => !!F,
373
421
  mode: () => mode,
374
422
  sel: () => ({ layers: [...sel.layers], thicknesses: [...sel.thicknesses], colors: [...sel.colors] }),
375
- shown: () => countShown(F.elements, mode, sel),
376
- total: () => nodes.length,
423
+ shown: () => countShown((sheets[active] && sheets[active].elements) || [], mode, sel),
424
+ total: () => ((sheets[active] && sheets[active].elements) || []).length,
425
+ sheetCount: () => sheets.length,
426
+ activeSheet: () => active,
427
+ setSheet: (i) => setSheet(i),
377
428
  pick: (i) => { const el = nodes[i].el; openEyedropper(Math.round(window.innerWidth / 2), 200, el); return el; },
378
429
  addAll: () => document.getElementById('eyeAddAll').click(),
379
430
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {