@floless/app 0.41.0 → 0.43.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.41.0" : void 0,
52859
+ define: true ? "0.43.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.41.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.43.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -53752,18 +53752,23 @@ function bakeContractIntoApp(sourcePath, contract) {
53752
53752
  if (idx < 0) idx = items.findIndex((it) => nodeOf(it)?.agent === "steel-takeoff-us");
53753
53753
  if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
53754
53754
  doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
53755
- const plans = contract.plans;
53756
- const baked = Array.isArray(plans) ? {
53757
- ...contract,
53758
- plans: plans.map((p) => {
53755
+ const baked = { ...contract };
53756
+ if (Array.isArray(contract.plans)) {
53757
+ baked.plans = contract.plans.map((p) => {
53759
53758
  if (p && typeof p === "object") {
53760
53759
  const clone = { ...p };
53761
53760
  delete clone.dims;
53762
53761
  return clone;
53763
53762
  }
53764
53763
  return p;
53765
- })
53766
- } : contract;
53764
+ });
53765
+ }
53766
+ const filter = baked.filter;
53767
+ if (filter && typeof filter === "object" && filter.page && typeof filter.page === "object") {
53768
+ const page = { ...filter.page };
53769
+ delete page.bg_b64;
53770
+ baked.filter = { ...filter, page };
53771
+ }
53767
53772
  doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
53768
53773
  (0, import_node_fs17.writeFileSync)(sourcePath, doc2.toString());
53769
53774
  }
@@ -36,9 +36,89 @@
36
36
  },
37
37
  "additionalProperties": false
38
38
  },
39
- "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
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
41
  },
41
42
  "$defs": {
43
+ "filter": {
44
+ "type": "object",
45
+ "additionalProperties": true,
46
+ "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).",
47
+ "properties": {
48
+ "page": {
49
+ "type": "object",
50
+ "required": ["w", "h"],
51
+ "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 …\").",
52
+ "properties": {
53
+ "w": { "type": "number", "minimum": 1 },
54
+ "h": { "type": "number", "minimum": 1 },
55
+ "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." }
56
+ }
57
+ },
58
+ "steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of the steel linework, for Zoom-to-steel." },
59
+ "mode": { "enum": ["thickness", "layer", "color", "thickness+layer", "thickness+color"], "description": "Active match mode." },
60
+ "hide_title_block": { "type": "boolean" },
61
+ "layers": {
62
+ "type": "array",
63
+ "items": {
64
+ "type": "object",
65
+ "required": ["name"],
66
+ "additionalProperties": true,
67
+ "properties": {
68
+ "name": { "type": "string" },
69
+ "count": { "type": "number" },
70
+ "steel": { "type": "boolean", "description": "Heuristic steel layer — defaults ON at first load." },
71
+ "drawable": { "type": "boolean", "description": "false = listed but not drawn (e.g. a text/title-block layer)." },
72
+ "on": { "type": "boolean", "description": "Persisted selection state." }
73
+ }
74
+ }
75
+ },
76
+ "thicknesses": {
77
+ "type": "array",
78
+ "items": {
79
+ "type": "object",
80
+ "required": ["w"],
81
+ "additionalProperties": true,
82
+ "properties": {
83
+ "w": { "type": "number", "description": "Stroke width in pt." },
84
+ "count": { "type": "number" },
85
+ "on": { "type": "boolean" }
86
+ }
87
+ }
88
+ },
89
+ "colors": {
90
+ "type": "array",
91
+ "items": {
92
+ "type": "object",
93
+ "required": ["hex"],
94
+ "additionalProperties": true,
95
+ "properties": {
96
+ "hex": { "type": "string" },
97
+ "count": { "type": "number" },
98
+ "on": { "type": "boolean" }
99
+ }
100
+ }
101
+ },
102
+ "elements": {
103
+ "type": "array",
104
+ "description": "Every drawing element: lines (with an SVG path `d`) and text spans (with `text`+`bbox`), each tagged layer/thickness/colour.",
105
+ "items": {
106
+ "type": "object",
107
+ "additionalProperties": true,
108
+ "properties": {
109
+ "kind": { "enum": ["line", "text"] },
110
+ "layer": { "type": "string" },
111
+ "w": { "type": "number" },
112
+ "color": { "type": "string" },
113
+ "d": { "type": "string", "description": "SVG path (line elements)." },
114
+ "text": { "type": "string", "description": "Label text (text elements)." },
115
+ "bbox": { "type": "array", "items": { "type": "number" }, "description": "[x0,y0,x1,y1] (text elements)." },
116
+ "dashed": { "type": "boolean" }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ },
42
122
  "point2": {
43
123
  "type": "array",
44
124
  "items": { "type": "number" },
@@ -39,8 +39,48 @@ file) and optionally `sourceName` (the original filename — use it for the `sou
39
39
  provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
40
40
  and proceed from that path.
41
41
 
42
+ ### 1b. Filter pre-stage — isolate the steel linework FIRST (the `filter` node)
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
69
+ (**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.
76
+
42
77
  ### 2. Read it with your own vision — the heavy pipeline
43
78
 
79
+ > **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.
83
+
44
84
  Follow the methodology condensed here (full detail + every gotcha in
45
85
  `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
46
86
 
package/dist/web/aware.js CHANGED
@@ -926,7 +926,12 @@
926
926
  // An html-report (static) or viewer-3d (interactive 3D) agent node renders HTML — its
927
927
  // `render` command returns an `html` field, exactly what extractReportHtml keys on. So
928
928
  // it's a report producer regardless of exec code; prefer it directly as the viewer node.
929
- const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report' || n.agent === 'viewer-3d');
929
+ // A pre-stage authoring node (config.surface, e.g. the steel `filter` node) renders a summary
930
+ // but is NOT the workflow's report viewer — its affordance is its own surface button. Skip it,
931
+ // or prepending it would steal "View report ▸" from the real first report node (e.g. `read`).
932
+ const htmlReportNode = app.nodes.find(
933
+ (n) => (n.agent === 'html-report' || n.agent === 'viewer-3d') && !(n.config && n.config.surface),
934
+ );
930
935
  if (htmlReportNode) return htmlReportNode.id;
931
936
  // Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
932
937
  // code returns an `html` field (the `data.result.html` extractReportHtml keys
@@ -1068,6 +1073,11 @@
1068
1073
  teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1069
1074
  card.dataset.tip = 'Double-click to open the contract editor';
1070
1075
  }
1076
+ // Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
1077
+ if (node && node.config && node.config.surface === 'steel-filter') {
1078
+ addNodeAction(card, 'Filter ▸', () => openFilterView(currentId));
1079
+ if (!card.dataset.tip) card.dataset.tip = 'Double-click to open the steel filter';
1080
+ }
1071
1081
  });
1072
1082
  }
1073
1083
 
@@ -1428,6 +1438,9 @@
1428
1438
  function openContractEditor(appId, type) {
1429
1439
  const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1430
1440
  if (!r) return false;
1441
+ // The editor bakes the .lock on Approve — that control belongs to the contract editor, not the
1442
+ // filter view (which has its own Save). Make sure it's visible here (openFilterView hides it).
1443
+ const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = false;
1431
1444
  $contractEditorTitle.replaceChildren(
1432
1445
  Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1433
1446
  Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
@@ -1439,6 +1452,22 @@
1439
1452
  $contractEditor.hidden = false;
1440
1453
  return true;
1441
1454
  }
1455
+ // The steel filter pre-stage view — reuses the contract-editor overlay/iframe to load the served
1456
+ // steel-filter.html (layer/thickness/colour facets + eyedropper). Same seam as the editor: the
1457
+ // iframe talks to /api/contract directly and Save writes contract.filter.
1458
+ function openFilterView(appId) {
1459
+ // The filter view has its own Save; the overlay's "Approve & bake lock" (editor-only) would
1460
+ // be a confusing no-op here, so hide it. openContractEditor restores it.
1461
+ const ap = document.getElementById('contract-editor-approve'); if (ap) ap.hidden = true;
1462
+ $contractEditorTitle.replaceChildren(
1463
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1464
+ Object.assign(document.createElement('span'), { textContent: ' · steel filter', style: 'color:var(--text-muted)' }),
1465
+ );
1466
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1467
+ $contractEditorFrame.src = '/steel-filter.html?app=' + encodeURIComponent(appId);
1468
+ $contractEditor.hidden = false;
1469
+ return true;
1470
+ }
1442
1471
  function closeContractEditor() {
1443
1472
  $contractEditor.hidden = true;
1444
1473
  $contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
@@ -2061,6 +2090,7 @@
2061
2090
  const node = app && (app.nodes || []).find((n) => n.id === id);
2062
2091
  const ct = window.contractTypeOf && window.contractTypeOf(node);
2063
2092
  if (ct && openContractEditor(currentId, ct)) return;
2093
+ if (node && node.config && node.config.surface === 'steel-filter') { openFilterView(currentId); return; }
2064
2094
  if (id === reportNodeId()) showReport(id);
2065
2095
  else if (id === inputNodeId()) openInputsDialog();
2066
2096
  });
@@ -143,7 +143,7 @@
143
143
  #lbView.drag{cursor:grabbing}
144
144
  #lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
145
145
  line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
146
- circle.snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
146
+ .snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
147
147
  line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
148
148
  line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
149
149
  circle.dimend{fill:#22d3ee;pointer-events:none}
@@ -960,24 +960,34 @@ function rectHit(p0,p1,r){
960
960
  const c=[[r[0],r[1]],[r[2],r[1]],[r[2],r[3]],[r[0],r[3]]];
961
961
  for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
962
962
  return false;}
963
- // --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections); Alt = off ---
964
- const SNAP_PX=9; let snapPts=[], snapSegs=[];
963
+ // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
964
+ const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], lastSnapKind='end'; // lastSnapKind drives the marker glyph: 'mid' → triangle, else circle
965
+ const _mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
965
966
  function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
966
- snapPts=[];snapSegs=[];
967
- for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);}
968
- for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);}}
967
+ snapPts=[];snapSegs=[];snapMids=[];
968
+ for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);snapMids.push(_mid(m.wp[0],m.wp[1]));}
969
+ for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);snapMids.push(_mid(s.a,s.b));}
970
+ if(dimsVisible&&Array.isArray(P.dims))for(const d of P.dims){if(ex.has(d.id))continue; // snap to a dimension's VISIBLE (offset) line — its ends, midpoint, and onto-line (dimGeo already gives g.mid)
971
+ const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}}
969
972
  // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
970
973
  function nearestOnLine(x,y,tol){let bp=null,bd=tol;
971
974
  for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
972
975
  return bp?{x:bp[0],y:bp[1],d:bd}:null;}
973
- function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
976
+ function snap(x,y){const tol=SNAP_PX/zoom;lastSnapKind='end';let bp=null,bd=tol;
974
977
  for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
975
- if(bp)return {x:bp[0],y:bp[1],hit:true}; // 1) snap to an endpoint/grid intersection
976
- const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true}; // 2) else snap ONTO a line (not only its endpoints)
977
- let sx=x,sy=y,bx=tol,by=tol; // 3) else axis-align to a nearby point
978
+ if(bp)return {x:bp[0],y:bp[1],hit:true,kind:'end'}; // 1) endpoint / grid intersection (highest priority)
979
+ let mp=null,md=tol;for(const q of snapMids){const d=Math.hypot(q[0]-x,q[1]-y);if(d<md){md=d;mp=q;}}
980
+ if(mp){lastSnapKind='mid';return {x:mp[0],y:mp[1],hit:true,kind:'mid'};} // 2) midpoint of a member/segment/dimension line (△)
981
+ const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true,kind:'line'}; // 3) else snap ONTO a line (not only its endpoints)
982
+ let sx=x,sy=y,bx=tol,by=tol; // 4) else axis-align to a nearby point
978
983
  for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
979
- return {x:sx,y:sy,hit:false};}
980
- function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
984
+ return {x:sx,y:sy,hit:false,kind:null};}
985
+ // one persistent marker element; kind 'mid' draws an upward triangle, everything else the original circle. Defaults to the last snap()'s kind.
986
+ function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
987
+ const ns='http://www.w3.org/2000/svg';let el;
988
+ if(kind==='mid'){const r=8/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x} ${y-r} L ${x+0.866*r} ${y+0.5*r} L ${x-0.866*r} ${y+0.5*r} Z`);} // apex-up equilateral, centroid ON the snap point
989
+ else{el=document.createElementNS(ns,'circle');el.setAttribute('cx',x);el.setAttribute('cy',y);el.setAttribute('r',6/zoom);}
990
+ el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);}
981
991
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
982
992
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
983
993
  // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
@@ -1112,7 +1122,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1112
1122
  if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
1113
1123
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
1114
1124
  if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
1115
- if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(null);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps)
1125
+ if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(id);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps; exclude this dim so the end can't snap to its own line/midpoint)
1116
1126
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
1117
1127
  if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
1118
1128
  selDimIds=new Set([did]);const d=P.dims.find(x=>x.id===did); // plain click → exactly this dim (collapses any multi-selection, so Delete removes only it)
@@ -1156,7 +1166,7 @@ svg.addEventListener('pointermove',e=>{
1156
1166
  if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
1157
1167
  if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
1158
1168
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
1159
- if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1]);}else snapClear();}return;}
1169
+ if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1],'end');}else snapClear();}return;} // the cut lands ON the member → always the neutral circle, never a stale midpoint △ (e.g. Alt held = no snap() this frame)
1160
1170
  if(!drag)return;const p=toSvg(e);
1161
1171
  if(drag.type==='dimend'){const d=P.dims.find(x=>x.id===drag.id);if(d){let x=p.x,y=p.y;if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();if(drag.end===0)d.a=[x,y];else d.b=[x,y];render();}return;} // dragging a dim anchor → snap + re-measure live
1162
1172
  if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y],d.rot);render();}return;}
@@ -1168,11 +1178,12 @@ svg.addEventListener('pointermove',e=>{
1168
1178
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
1169
1179
  if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
1170
1180
  if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
1171
- else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
1181
+ else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, midpoint △, OR line)
1172
1182
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
1173
- for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
1174
- const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny};}} // also snap onto other lines
1175
- if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1]);}else snapClear();}
1183
+ for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'end'};}}
1184
+ for(const q of snapMids){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'mid'};}}
1185
+ const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny,kind:'line'};}} // also snap onto other lines
1186
+ if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1],best.kind);}else snapClear();}
1176
1187
  else snapClear();
1177
1188
  for(const it of drag.items){const m=byId(it.id);if(!m)continue;
1178
1189
  m.wp[0]=[it.o0[0]+dx,it.o0[1]+dy];m.wp[1]=[it.o1[0]+dx,it.o1[1]+dy];updateLine(m);}
@@ -1704,6 +1715,17 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
1704
1715
  {const tz={frame:{o:[0,0],u:[0,0]}};sanitizeFrame(tz);ok(tz.frame===null,'sanitizeFrame drops zero-length u');}
1705
1716
  {const tb={frame:{o:[0,0],u:[1]}};sanitizeFrame(tb);ok(tb.frame===null,'sanitizeFrame drops malformed u');}
1706
1717
  P.frame=sf; // restore
1718
+ // 6) snap — midpoint tier (△) + dimension lines as snap targets + self-snap exclusion
1719
+ {const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom;
1720
+ zoom=1;P.members=[{id:'tm',wp:[[0,0],[100,0]]}];P.segments=[];P.dims=[];dimsVisible=true;
1721
+ buildSnap(null);
1722
+ const e1=snap(1,1);ok(e1.hit&&e1.kind==='end'&&pnear([e1.x,e1.y],[0,0]),'snap → endpoint wins near a corner');
1723
+ const m1=snap(50,3);ok(m1.hit&&m1.kind==='mid'&&pnear([m1.x,m1.y],[50,0]),'snap → member midpoint (△)');
1724
+ P.dims=[{id:'td',a:[0,0],b:[100,0],axis:'free',off:20,rot:0}]; // visible dim line runs (0,20)→(100,20), mid (50,20)
1725
+ buildSnap(null);
1726
+ const d1=snap(50,18);ok(d1.hit&&d1.kind==='mid'&&pnear([d1.x,d1.y],[50,20]),'snap → dimension-line midpoint (△)');
1727
+ buildSnap('td');const d2=snap(50,18);ok(!d2.hit,'snap → excluded dim cannot self-snap');
1728
+ P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
1707
1729
  const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
1708
1730
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
1709
1731
  })();}
@@ -0,0 +1,79 @@
1
+ /*
2
+ * steel-filter-core.js — PURE, framework-free core for the steel filter pre-stage (no DOM, no
3
+ * window). Unit-tested in node (server/steel-filter-core.test.ts) AND loaded by web/steel-filter.html.
4
+ *
5
+ * The filter pre-stage isolates the real steel linework on a drawing before the takeoff reader binds
6
+ * profile labels to it. The host AI runs ONE PyMuPDF pass at compose time and writes a `filter` block
7
+ * onto the steel.takeoff/v1 contract; this module is the pure match/selection logic the view and the
8
+ * reader both reason about. Lifted from the steel-parts-from-pdf.flo spike (`matchesActive`), extended
9
+ * with persistence (`on` flags) and the eyedropper add.
10
+ *
11
+ * Shapes:
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'
14
+ * sel : { layers:Set<string>, thicknesses:Set<number>, colors:Set<string> }
15
+ * filter : { mode, layers:[{name,count,steel?,drawable?,on?}], thicknesses:[{w,count,on?}], colors:[{hex,count,on?}], elements:[…] }
16
+ */
17
+
18
+ /** The active match predicate — does this element pass the current mode + selection? */
19
+ 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
+ }
31
+ }
32
+
33
+ /**
34
+ * Build the in-memory selection Sets from a persisted filter block's `on` flags. A missing `on`
35
+ * defaults to the sensible first-load state: layers default to their `steel` flag, thicknesses and
36
+ * colors default ON (so switching into those modes shows everything until the user narrows).
37
+ */
38
+ export function selFromFilter(filter) {
39
+ const onOr = (it, dflt) => (it.on === undefined ? dflt : !!it.on);
40
+ return {
41
+ layers: new Set((filter.layers || []).filter((l) => onOr(l, !!l.steel)).map((l) => l.name)),
42
+ thicknesses: new Set((filter.thicknesses || []).filter((t) => onOr(t, true)).map((t) => t.w)),
43
+ colors: new Set((filter.colors || []).filter((c) => onOr(c, true)).map((c) => c.hex)),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Eyedropper: add a picked element's facet value(s) to the selection (additive — never clears).
49
+ * `which` is 'layer' | 'thickness' | 'color' | 'all'. Returns the same (mutated) sel for chaining.
50
+ */
51
+ export function eyedropperAdd(sel, el, which) {
52
+ // Text spans / hairlines (w<=0) have no meaningful stroke width — the popover hides their
53
+ // Thickness row, so "all" must not slip a w:0 into the thickness selection (which would then
54
+ // match every text/hairline element in any thickness mode — the opposite of isolating steel).
55
+ const hasStroke = el.kind !== 'text' && el.w > 0;
56
+ if (which === 'layer' || which === 'all') sel.layers.add(el.layer);
57
+ if ((which === 'thickness' || which === 'all') && hasStroke) sel.thicknesses.add(el.w);
58
+ if (which === 'color' || which === 'all') sel.colors.add(el.color);
59
+ return sel;
60
+ }
61
+
62
+ /** Count how many elements are visible under the current mode + selection. */
63
+ export function countShown(elements, mode, sel) {
64
+ let n = 0;
65
+ for (const el of elements) if (matchesActive(el, mode, sel)) n++;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * Write the live selection + mode back onto the filter block's `on` flags so it persists on the
71
+ * contract (what Save PUTs, and what the reader reads). Mutates and returns `filter`.
72
+ */
73
+ export function applySelToFilter(filter, sel, mode) {
74
+ filter.mode = mode;
75
+ for (const l of filter.layers || []) l.on = sel.layers.has(l.name);
76
+ for (const t of filter.thicknesses || []) t.on = sel.thicknesses.has(t.w);
77
+ for (const c of filter.colors || []) c.on = sel.colors.has(c.hex);
78
+ return filter;
79
+ }
@@ -0,0 +1,384 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Steel Filter</title>
7
+ <style>
8
+ :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
9
+ /* Theme EVERY scrollbar — no native white default may leak (panels, popovers, modal lists). */
10
+ *{scrollbar-width:thin;scrollbar-color:#475569 transparent}
11
+ *::-webkit-scrollbar{width:10px;height:10px}
12
+ *::-webkit-scrollbar-track{background:transparent}
13
+ *::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
14
+ *::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
15
+ *::-webkit-scrollbar-corner{background:transparent}
16
+ *{box-sizing:border-box}
17
+ html,body{height:100%}
18
+ body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}
19
+ header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
20
+ header b{font-size:14px} header .sp{flex:1}
21
+ .stat{color:var(--mut);font-size:12px} .stat b{color:var(--text)}
22
+ .stat.dirty b{color:#f59e0b} .stat.saved b{color:#34d399} .stat.err b{color:#f87171}
23
+ button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
24
+ button:hover{background:#475569} button:disabled{opacity:.5;cursor:default}
25
+ button.primary{background:var(--brand);border-color:var(--brand);color:#fff} button.primary:hover{background:#2f6fe0}
26
+ button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
27
+ #app{display:flex;flex:1;min-height:0}
28
+ #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
+ #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}
30
+ .acts{display:inline-flex;gap:4px}
31
+ .acts button{font-size:11px;padding:2px 7px;background:transparent;border-color:var(--line);color:var(--mut)}
32
+ .acts button:hover{background:transparent;border-color:var(--brand);color:var(--text)}
33
+ .facet{border-top:1px solid var(--line);padding-top:14px}
34
+ #modes label,.opts label{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;cursor:pointer;color:var(--text)}
35
+ .opts label em{margin-left:auto;color:var(--mut);font-style:normal;font-size:11px}
36
+ .opts .sw{width:12px;height:12px;border-radius:3px;flex:0 0 12px;border:1px solid var(--line)}
37
+ .opts label.textlayer{opacity:.55;cursor:default}
38
+ /* Fully custom checkbox/radio — the native unchecked widget is a white/grey square that breaks the dark baseline. */
39
+ 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}
40
+ input[type=radio]{border-radius:50%}
41
+ 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}
42
+ input[type=radio]:checked{background:var(--brand);border-color:var(--brand);box-shadow:inset 0 0 0 3px var(--panel)}
43
+ input:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
44
+ .facet.dim{opacity:.4} .facet.dim .opts,.facet.dim .acts{pointer-events:none}
45
+ .hint{color:var(--mut);font-size:11px;line-height:1.5}
46
+ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
47
+ #count{font-size:11px;color:var(--mut);width:100%;margin-bottom:4px}
48
+ #count b{color:var(--text)}
49
+ #stage{flex:1;position:relative;background:var(--bg);min-width:0}
50
+ #canvas{width:100%;height:100%;cursor:grab;touch-action:none;display:block}
51
+ #canvas.dragging{cursor:grabbing}
52
+ #canvas image.bg{opacity:.28}
53
+ #canvas path.el{pointer-events:stroke}
54
+ #canvas text.el{pointer-events:all;cursor:pointer;user-select:none}
55
+ #empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;padding:40px;text-align:center;color:var(--mut)}
56
+ #empty.show{display:flex}
57
+ #zoombar{position:absolute;left:12px;bottom:12px;display:flex;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 8px;box-shadow:0 4px 14px rgba(0,0,0,.45)}
58
+ /* eyedropper popover */
59
+ #eye{position:fixed;display:none;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px;box-shadow:0 8px 24px rgba(0,0,0,.5);min-width:210px}
60
+ #eye.show{display:block}
61
+ #eye .ehead{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
62
+ #eye .erow{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px}
63
+ #eye .erow .k{color:var(--mut);min-width:72px;flex:none}
64
+ #eye .erow .v{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
65
+ #eye .erow .sw{width:12px;height:12px;border-radius:3px;border:1px solid var(--line);flex:none}
66
+ #eye .erow button{font-size:11px;padding:2px 7px}
67
+ #eye .eall{margin-top:8px;width:100%}
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <header>
72
+ <b>Steel filter</b>
73
+ <span id="src" class="stat"></span>
74
+ <span class="sp"></span>
75
+ <span id="saved" class="stat"></span>
76
+ <button id="save" class="primary" disabled>Save</button>
77
+ </header>
78
+ <div id="app">
79
+ <aside id="panel">
80
+ <section id="modes">
81
+ <h2>Match mode</h2>
82
+ <label><input type="radio" name="mode" value="layer" checked /> Layer only</label>
83
+ <label><input type="radio" name="mode" value="thickness" /> Thickness only</label>
84
+ <label><input type="radio" name="mode" value="color" /> Color only</label>
85
+ <label><input type="radio" name="mode" value="thickness+layer" /> Thickness + Layer</label>
86
+ <label><input type="radio" name="mode" value="thickness+color" /> Thickness + Color</label>
87
+ </section>
88
+ <section class="facet" id="facet-layers">
89
+ <h2>Layers <span class="acts"><button data-all="layers">all</button><button data-none="layers">none</button></span></h2>
90
+ <div class="opts"></div>
91
+ </section>
92
+ <section class="facet" id="facet-thicknesses">
93
+ <h2>Thicknesses <span class="acts"><button data-all="thicknesses">all</button><button data-none="thicknesses">none</button></span></h2>
94
+ <div class="opts"></div>
95
+ </section>
96
+ <section class="facet" id="facet-colors">
97
+ <h2>Colors <span class="acts"><button data-all="colors">all</button><button data-none="colors">none</button></span></h2>
98
+ <div class="opts"></div>
99
+ </section>
100
+ <footer>
101
+ <span id="count"></span>
102
+ <button id="fit">Fit page</button>
103
+ <button id="zoomsteel">Zoom to steel</button>
104
+ </footer>
105
+ </aside>
106
+ <main id="stage">
107
+ <svg id="canvas" preserveAspectRatio="xMidYMid meet">
108
+ <image class="bg"></image>
109
+ <g id="overlay" fill="none"></g>
110
+ </svg>
111
+ <div id="empty">
112
+ <div>
113
+ <p>No filter data yet.</p>
114
+ <p class="hint" style="margin-top:8px;max-width:340px">Your terminal AI reads the drawing once (PyMuPDF) and bakes its layers, thicknesses and colours here. Use <b>Re-read &amp; re-bake ▸</b> on the canvas, then reload.</p>
115
+ </div>
116
+ </div>
117
+ <div id="zoombar"><button id="fit2">Fit</button><button id="zoomsteel2">Steel</button></div>
118
+ </main>
119
+ </div>
120
+ <div id="eye">
121
+ <div class="ehead"><span>Picked element</span><button id="eyeClose" style="padding:0 6px">✕</button></div>
122
+ <div id="eyeBody"></div>
123
+ <button class="eall primary" id="eyeAddAll">Add all to filter</button>
124
+ </div>
125
+
126
+ <script type="module">
127
+ import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter } from './steel-filter-core.js';
128
+
129
+ const APP_ID = new URLSearchParams(location.search).get('app') || '';
130
+ const SVGNS = 'http://www.w3.org/2000/svg';
131
+ const PALETTE = ['#3b82f6','#22d3ee','#a78bfa','#f59e0b','#34d399','#f472b6','#60a5fa','#facc15'];
132
+
133
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
134
+ const safeColor = c => /^#[0-9a-fA-F]{3,8}$/.test(String(c)) ? c : 'transparent';
135
+
136
+ const svg = document.getElementById('canvas');
137
+ const overlay = document.getElementById('overlay');
138
+ const bgImg = svg.querySelector('image.bg');
139
+ const countEl = document.getElementById('count');
140
+ const savedEl = document.getElementById('saved');
141
+ const saveBtn = document.getElementById('save');
142
+
143
+ let C = null; // the full contract
144
+ let F = null; // C.filter
145
+ let sel = null; // { layers, thicknesses, colors } Sets
146
+ let mode = 'layer';
147
+ let nodes = []; // [{ node, el }]
148
+ let layerColor = {};
149
+ let dirty = false;
150
+
151
+ function setSaved(state){
152
+ savedEl.className = 'stat ' + (state || '');
153
+ savedEl.innerHTML = state === 'dirty' ? 'Unsaved <b>●</b>' : state === 'saved' ? 'Saved <b>✓</b>' : state === 'err' ? 'Save failed <b>✕</b>' : '';
154
+ }
155
+ function markDirty(){ dirty = true; saveBtn.disabled = false; setSaved('dirty'); }
156
+
157
+ async function boot(){
158
+ if(!APP_ID){ showEmpty('No app specified.'); return; }
159
+ let res;
160
+ try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID)); }
161
+ catch(e){ showEmpty('Could not reach the server.'); return; }
162
+ if(!res.ok){ showEmpty(); return; }
163
+ C = await res.json();
164
+ F = C && C.filter;
165
+ if(!F || !Array.isArray(F.elements) || !F.elements.length){ showEmpty(); return; }
166
+ document.getElementById('empty').classList.remove('show');
167
+ document.getElementById('src').textContent = (C.source && C.source.name) ? ('· ' + C.source.name) : '';
168
+ initFromFilter();
169
+ }
170
+
171
+ function showEmpty(msg){
172
+ const e = document.getElementById('empty');
173
+ if(msg) e.querySelector('p').textContent = msg;
174
+ e.classList.add('show');
175
+ }
176
+
177
+ function initFromFilter(){
178
+ mode = F.mode || 'layer';
179
+ document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
180
+ document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
181
+ // layer colours: steel layers vivid+distinct, others a single muted slate
182
+ let si = 0;
183
+ layerColor = {};
184
+ for(const l of F.layers || []) layerColor[l.name] = l.steel ? PALETTE[si++ % PALETTE.length] : '#94a3b8';
185
+ 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
188
+ svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
189
+ 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
+ else bgImg.style.display = 'none';
191
+ 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 };
196
+ apply();
197
+ }
198
+
199
+ function buildOverlay(){
200
+ overlay.replaceChildren();
201
+ nodes = (F.elements || []).map(el => {
202
+ let n;
203
+ if(el.kind === 'text'){
204
+ n = document.createElementNS(SVGNS, 'text');
205
+ const b = el.bbox || [0,0,0,0];
206
+ n.setAttribute('x', b[0]); n.setAttribute('y', b[3] || b[1]);
207
+ n.setAttribute('font-size', Math.max(8, (b[3]-b[1]) || 10));
208
+ n.setAttribute('fill', layerColor[el.layer] || '#cbd5e1');
209
+ n.textContent = el.text || '';
210
+ } else {
211
+ n = document.createElementNS(SVGNS, 'path');
212
+ n.setAttribute('d', el.d || '');
213
+ n.setAttribute('stroke', layerColor[el.layer] || '#3b82f6');
214
+ n.setAttribute('stroke-width', '2.2');
215
+ n.setAttribute('stroke-linecap', 'round');
216
+ n.setAttribute('vector-effect', 'non-scaling-stroke');
217
+ if(el.dashed) n.setAttribute('stroke-dasharray', '7 4');
218
+ }
219
+ n.setAttribute('class', 'el');
220
+ n.__el = el;
221
+ overlay.appendChild(n);
222
+ return { node: n, el };
223
+ });
224
+ }
225
+
226
+ function setOf(facet){ return facet === 'layers' ? sel.layers : facet === 'thicknesses' ? sel.thicknesses : sel.colors; }
227
+
228
+ function buildFacet(facet, items, valueKey, label, selValue){
229
+ const box = document.querySelector(`#facet-${facet} .opts`);
230
+ box.replaceChildren();
231
+ for(const it of items){
232
+ const val = it[valueKey];
233
+ const isText = facet === 'layers' && it.drawable === false;
234
+ const wrap = document.createElement('label');
235
+ if(isText) wrap.className = 'textlayer';
236
+ const cb = document.createElement('input');
237
+ cb.type = 'checkbox';
238
+ cb.checked = setOf(facet).has(selValue(it));
239
+ cb.value = String(val);
240
+ cb.addEventListener('change', () => { const s = setOf(facet); cb.checked ? s.add(selValue(it)) : s.delete(selValue(it)); markDirty(); apply(); });
241
+ wrap.appendChild(cb);
242
+ if(facet === 'layers'){
243
+ const sw = document.createElement('span'); sw.className = 'sw';
244
+ sw.style.background = layerColor[val] || 'transparent';
245
+ wrap.appendChild(sw);
246
+ } else if(facet === 'colors'){
247
+ const sw = document.createElement('span'); sw.className = 'sw';
248
+ sw.style.background = safeColor(val);
249
+ wrap.appendChild(sw);
250
+ }
251
+ wrap.appendChild(document.createTextNode(' ' + label(it) + ' '));
252
+ const c = document.createElement('em'); c.textContent = it.count; wrap.appendChild(c);
253
+ box.appendChild(wrap);
254
+ }
255
+ }
256
+
257
+ function syncFacetChecks(){
258
+ for(const facet of ['layers','thicknesses','colors']){
259
+ const items = F[facet] || [];
260
+ const cbs = document.querySelectorAll(`#facet-${facet} input[type=checkbox]`);
261
+ cbs.forEach((cb, i) => {
262
+ const it = items[i]; if(!it) return;
263
+ const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex);
264
+ cb.checked = setOf(facet).has(v);
265
+ });
266
+ }
267
+ }
268
+
269
+ function apply(){
270
+ for(const { node, el } of nodes){
271
+ const vis = matchesActive(el, mode, sel);
272
+ node.style.display = vis ? '' : 'none';
273
+ }
274
+ countEl.innerHTML = '<b>' + countShown(F.elements, mode, sel) + '</b> of <b>' + nodes.length + '</b> elements shown';
275
+ document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
276
+ document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
277
+ document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
278
+ }
279
+
280
+ // --- modes + all/none ---
281
+ document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => { if(r.checked){ mode = r.value; markDirty(); apply(); } }));
282
+ document.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.all, true)));
283
+ document.querySelectorAll('[data-none]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.none, false)));
284
+ function setAllFacet(facet, on){
285
+ const s = setOf(facet);
286
+ const items = F[facet] || [];
287
+ for(const it of items){ const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex); on ? s.add(v) : s.delete(v); }
288
+ syncFacetChecks(); markDirty(); apply();
289
+ }
290
+
291
+ // --- eyedropper: pick a line/label -> popover, + Add each / Add all ---
292
+ const eye = document.getElementById('eye');
293
+ let eyeEl = null;
294
+ function openEyedropper(clientX, clientY, el){
295
+ eyeEl = el;
296
+ const body = document.getElementById('eyeBody');
297
+ const rows = [];
298
+ rows.push(eyeRow('Layer', el.layer, 'layer', `<span class="sw" style="background:${safeColor(layerColor[el.layer])}"></span>`));
299
+ if(el.kind !== 'text' && el.w > 0) rows.push(eyeRow('Thickness', el.w + ' pt', 'thickness', ''));
300
+ rows.push(eyeRow('Color', el.color, 'color', `<span class="sw" style="background:${safeColor(el.color)}"></span>`));
301
+ body.innerHTML = rows.join('');
302
+ body.querySelectorAll('button[data-add]').forEach(b => b.onclick = () => { eyedropperAdd(sel, el, b.dataset.add); syncFacetChecks(); markDirty(); apply(); closeEye(); });
303
+ eye.classList.add('show');
304
+ const w = eye.offsetWidth || 220, h = eye.offsetHeight || 160;
305
+ eye.style.left = Math.min(clientX + 8, window.innerWidth - w - 8) + 'px';
306
+ eye.style.top = Math.min(clientY + 8, window.innerHeight - h - 8) + 'px';
307
+ }
308
+ function eyeRow(k, v, which, swatch){
309
+ return `<div class="erow"><span class="k">${k}</span>${swatch}<span class="v" title="${esc(v)}">${esc(v)}</span><button data-add="${which}">+ Add</button></div>`;
310
+ }
311
+ function closeEye(){ eye.classList.remove('show'); eyeEl = null; }
312
+ document.getElementById('eyeClose').onclick = closeEye;
313
+ document.getElementById('eyeAddAll').onclick = () => { if(eyeEl){ eyedropperAdd(sel, eyeEl, 'all'); syncFacetChecks(); markDirty(); apply(); } closeEye(); };
314
+
315
+ // --- pan/zoom via viewBox; a click (no drag) on an element opens the eyedropper ---
316
+ let base = { x:0, y:0, w:1000, h:1000 }, vb = { ...base }, drag = null;
317
+ function setVB(){ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); }
318
+ svg.addEventListener('wheel', e => {
319
+ e.preventDefault();
320
+ const r = svg.getBoundingClientRect();
321
+ const mx = vb.x + ((e.clientX - r.left) / r.width) * vb.w;
322
+ const my = vb.y + ((e.clientY - r.top) / r.height) * vb.h;
323
+ const k = e.deltaY < 0 ? 0.85 : 1 / 0.85;
324
+ vb.w *= k; vb.h *= k;
325
+ vb.x = mx - ((e.clientX - r.left) / r.width) * vb.w;
326
+ vb.y = my - ((e.clientY - r.top) / r.height) * vb.h;
327
+ setVB();
328
+ }, { passive: false });
329
+ svg.addEventListener('pointerdown', e => {
330
+ drag = { x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y, moved: false, target: e.target };
331
+ svg.setPointerCapture(e.pointerId); svg.classList.add('dragging');
332
+ });
333
+ svg.addEventListener('pointermove', e => {
334
+ if(!drag) return;
335
+ if(Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 3) drag.moved = true;
336
+ const r = svg.getBoundingClientRect();
337
+ vb.x = drag.vx - ((e.clientX - drag.x) / r.width) * vb.w;
338
+ vb.y = drag.vy - ((e.clientY - drag.y) / r.height) * vb.h;
339
+ setVB();
340
+ });
341
+ svg.addEventListener('pointerup', e => {
342
+ svg.classList.remove('dragging');
343
+ if(drag && !drag.moved && drag.target && drag.target.__el) openEyedropper(e.clientX, e.clientY, drag.target.__el);
344
+ else if(drag && !drag.moved) closeEye();
345
+ drag = null;
346
+ });
347
+ 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(); }
349
+ document.getElementById('fit').onclick = fit;
350
+ document.getElementById('fit2').onclick = fit;
351
+ document.getElementById('zoomsteel').onclick = zoomSteel;
352
+ document.getElementById('zoomsteel2').onclick = zoomSteel;
353
+
354
+ // --- save: write the selection + mode onto C.filter and PUT the contract ---
355
+ saveBtn.onclick = save;
356
+ async function save(){
357
+ if(!C || !F) return;
358
+ applySelToFilter(F, sel, mode);
359
+ C.filter = F;
360
+ saveBtn.disabled = true; setSaved('');
361
+ try {
362
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
363
+ if(!res.ok) throw new Error(await res.text());
364
+ dirty = false; setSaved('saved');
365
+ } catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
366
+ }
367
+ window.addEventListener('beforeunload', e => { if(dirty){ e.preventDefault(); e.returnValue = ''; } });
368
+
369
+ // Test seam (mirrors steel-3d-view's debug()/probe()) — lets a headless E2E drive the eyedropper
370
+ // and inspect state deterministically without fragile sub-pixel clicks on thin SVG strokes.
371
+ window.__filterTest = {
372
+ ready: () => !!F,
373
+ mode: () => mode,
374
+ sel: () => ({ layers: [...sel.layers], thicknesses: [...sel.thicknesses], colors: [...sel.colors] }),
375
+ shown: () => countShown(F.elements, mode, sel),
376
+ total: () => nodes.length,
377
+ pick: (i) => { const el = nodes[i].el; openEyedropper(Math.round(window.innerWidth / 2), 200, el); return el; },
378
+ addAll: () => document.getElementById('eyeAddAll').click(),
379
+ };
380
+
381
+ boot();
382
+ </script>
383
+ </body>
384
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.41.0",
3
+ "version": "0.43.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": {