@floless/app 0.27.0 → 0.29.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.
@@ -0,0 +1,1019 @@
1
+ <!doctype html><html><head><meta charset=utf-8>
2
+ <title>Steel takeoff — editor</title>
3
+ <style>
4
+ :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
5
+ *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
6
+ header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
7
+ header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
8
+ button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
9
+ button:hover{background:#475569} button.on{background:var(--brand);border-color:var(--brand)}
10
+ button:disabled{opacity:.4;cursor:default;background:#334155}
11
+ .seg-group{display:inline-flex;border:1px solid #475569;border-radius:6px;overflow:hidden}
12
+ .seg-group button{border:0;border-radius:0;border-right:1px solid #475569}
13
+ .seg-group button:last-child{border-right:0}
14
+ main{flex:1;display:flex;min-height:0}
15
+ #stagewrap{flex:1;position:relative;min-width:0;display:flex}
16
+ #stage{flex:1;overflow:auto;background:#0b1220;display:flex;align-items:safe center;justify-content:safe center}
17
+ svg{display:block;touch-action:none;flex:none}
18
+ #zoombar{position:absolute;left:12px;bottom:12px;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:5}
19
+ #zoombar button{padding:1px 9px;font-size:15px}
20
+ #zoombar input[type=range]{width:120px;padding:0;accent-color:var(--brand)}
21
+ #zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
22
+ aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
23
+ aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
24
+ select,input{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:6px;width:100%;font:13px system-ui}
25
+ .row{margin:8px 0} .danger{background:#7f1d1d;border-color:#991b1b} .danger:hover{background:#991b1b}
26
+ .pill{display:inline-block;padding:1px 6px;border-radius:4px;font-size:11px}
27
+ line.member{cursor:move;stroke-width:6;stroke-linecap:round}
28
+ line.member.sel{stroke-width:7} line.member.rfi{stroke-dasharray:10 6}
29
+ circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
30
+ line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
31
+ line.seg:hover{stroke:#fbbf24;opacity:1;stroke-width:4}
32
+ rect.marquee{fill:rgba(59,130,246,.08);stroke:var(--brand);stroke-width:1;stroke-dasharray:5 4;vector-effect:non-scaling-stroke;pointer-events:none}
33
+ .legend span{display:inline-flex;align-items:center;gap:4px;margin:0 8px 4px 0;white-space:nowrap}
34
+ .sw{width:12px;height:12px;border-radius:2px;display:inline-block}
35
+ .swc{width:14px;height:14px;padding:0;border:1px solid #475569;border-radius:3px;cursor:pointer;background:none;flex:none;-webkit-appearance:none;appearance:none}
36
+ .swc::-webkit-color-swatch-wrapper{padding:0} .swc::-webkit-color-swatch{border:none;border-radius:2px}
37
+ .hint{color:var(--mut);font-size:12px;line-height:1.5}
38
+ .seg2{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:28px;margin-top:8px}
39
+ .seg2 button{flex:1;border:0;border-radius:0;background:transparent;color:var(--mut)}
40
+ .seg2 button.on{background:var(--brand);color:#fff}
41
+ .sect{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:0 0 4px}
42
+ .f{margin-top:8px} .unit{position:relative} .unit input{padding-right:34px}
43
+ .unit span{position:absolute;right:8px;top:50%;transform:translateY(-50%);color:var(--mut);font-size:11px;pointer-events:none}
44
+ .divrow{display:flex;align-items:center;gap:8px;margin:10px 0}
45
+ .divrow hr{flex:1;border:0;border-top:1px solid var(--line);margin:0}
46
+ .ghost{background:transparent;border:1px solid var(--line);color:var(--mut);font-size:11px;padding:2px 8px;border-radius:4px;white-space:nowrap;cursor:pointer}
47
+ .ghost:hover{color:var(--text);border-color:var(--brand)}
48
+ .ghostw{width:100%;height:28px;background:transparent;border:1px solid var(--line);color:var(--text);border-radius:6px;cursor:pointer}
49
+ .ghostw:hover{border-color:var(--brand)}
50
+ .elab{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:8px 0 2px}
51
+ .edec{display:block;text-align:right;color:var(--mut);font-size:11px;margin-top:2px;font-variant-numeric:tabular-nums}
52
+ .elabrow{display:flex;align-items:center;justify-content:space-between;margin:8px 0 2px}
53
+ .elabrow .elab{margin:0}
54
+ .defck{display:inline-flex;align-items:center;gap:4px;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);cursor:pointer}
55
+ .defck input{width:auto;margin:0;cursor:pointer}
56
+ input:disabled{opacity:.55;cursor:default}
57
+ .badge{display:block;background:var(--brand);color:#fff;text-align:center;font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:5px;border-radius:6px;margin-bottom:10px}
58
+ .pick{color:var(--brand)!important}
59
+ rect.lblhot{cursor:pointer;fill:rgba(251,191,36,.10);stroke:#fbbf24;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
60
+ rect.lblhot:hover{fill:rgba(251,191,36,.28)}
61
+ rect.dethot{cursor:pointer;fill:rgba(168,85,247,.16);stroke:#a855f7;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
62
+ rect.dethot:hover{fill:rgba(168,85,247,.34)}
63
+ text.dettx{fill:#ddd6fe;font:bold 11px system-ui;text-anchor:middle;pointer-events:none}
64
+ #detailsModal,#framesModal,#rfiModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
65
+ #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
66
+ .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
67
+ .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
68
+ #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
69
+ #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
70
+ .ftab td .combo{padding:4px 6px} .ftab .rea{color:var(--mut);font-size:12px} .rfichip{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:#7f1d1d;color:#fecaca;border-radius:9px;font-size:11px;padding:0 5px}
71
+ .mbackdrop{position:absolute;inset:0;background:rgba(2,8,23,.62)}
72
+ .mpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;width:min(880px,92vw);max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
73
+ .mhead{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line)}
74
+ .mhead b{font-size:14px} .mhead button{padding:2px 9px}
75
+ .mgrid{padding:14px;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:12px}
76
+ .dcard{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--bg)}
77
+ .dcard .dprev{height:120px;display:flex;align-items:center;justify-content:center;background:#0b1220;color:var(--mut);font-size:11px;text-align:center;padding:8px;line-height:1.4}
78
+ .dcard img{display:block;width:100%;height:120px;object-fit:contain;background:#fff}
79
+ .dcard .dmeta{padding:6px 9px;border-top:1px solid var(--line)}
80
+ .dcard .dt{font-weight:600} .dcard .ds{color:var(--mut);font-size:11px}
81
+ .ftab{width:100%;border-collapse:collapse;font-size:13px}
82
+ .ftab th{text-align:left;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.04em;padding:7px 10px;border-bottom:1px solid var(--line)}
83
+ .ftab td{padding:7px 10px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
84
+ .ftab tbody tr:hover{background:#0b1220} .ftab .gcell{font-weight:600;color:var(--brand)}
85
+ #comboPop{position:fixed;z-index:30;background:var(--panel);border:1px solid #475569;border-radius:6px;max-height:240px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.5);display:none}
86
+ #comboPop .opt{padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap}
87
+ #comboPop .opt:hover,#comboPop .opt.active{background:#334155}
88
+ #detailNewForm input#dnName{margin:2px 0 10px}
89
+ #dnDrop{border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:88px;display:flex;align-items:center;justify-content:center;gap:12px;text-align:center;color:var(--mut);cursor:pointer;padding:12px;font-size:12px}
90
+ #dnDrop:hover{border-color:var(--brand);color:var(--text)} #dnDrop.has{border-style:solid;border-color:var(--brand)}
91
+ #dnPrev{display:none;max-height:64px;max-width:128px;border-radius:4px;background:#fff;border:1px solid var(--line)}
92
+ #dnBtns{display:flex;gap:8px;justify-content:flex-end;margin-top:10px} #dnBtns button{padding:6px 14px}
93
+ #dnHint{margin-top:8px}
94
+ .dprevwrap{height:120px} .dprevwrap[data-det]{cursor:zoom-in} .dcard:hover .dprevwrap[data-det]{filter:brightness(1.04)}
95
+ .dname{width:100%;background:#0f172a;border:1px solid #475569;border-radius:4px;padding:3px 6px;font-weight:600;font-size:12px;color:var(--text)}
96
+ .dname:focus{outline:none;border-color:var(--brand)} .dmeta .ds .ddel{color:#fca5a5;text-decoration:underline}
97
+ #lightbox{position:fixed;inset:0;z-index:40;display:none;align-items:center;justify-content:center}
98
+ .lbpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;box-shadow:0 24px 70px rgba(0,0,0,.7)}
99
+ #lbView{position:relative;overflow:hidden;background:#fff;width:88vw;height:82vh;cursor:grab;touch-action:none}
100
+ #lbView.drag{cursor:grabbing}
101
+ #lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
102
+ line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
103
+ circle.snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
104
+ body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
105
+ body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
106
+ circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
107
+ text.numtx{fill:#fff;font:bold 12px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
108
+ #dupStat.has b{color:#fca5a5} #dupB.alert{background:#7f1d1d;border-color:#991b1b} #dupB.alert:hover{background:#991b1b}
109
+ #dupB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
110
+ header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
111
+ </style></head><body>
112
+ <header>
113
+ <b>Steel takeoff</b>
114
+ <select id=planSel title="Switch plan view"></select>
115
+ <span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
116
+ <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
117
+ <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
118
+ <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span><span style="flex:1"></span>
119
+ <button id=undoB title="Undo (Ctrl+Z)">↶</button>
120
+ <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
121
+ <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
122
+ <button id=mAdd title="Toggle add-member mode">Add member</button>
123
+ <button id=detailsBtn>Details</button>
124
+ <button id=framesBtn>Frames</button>
125
+ <button id=revertB title="Discard all saved edits and reload the detected contract">Revert</button>
126
+ <button id=exp>Export contract</button>
127
+ <button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
128
+ <button id=askAiBtn>Ask AI ▸</button>
129
+ </header>
130
+ <main>
131
+ <div id=stagewrap>
132
+ <div id=stage><svg id=svg></svg></div>
133
+ <div id=zoombar>
134
+ <button id=zOut title="Zoom out">−</button>
135
+ <input id=zRange type=range min=10 max=400 step=1 value=100>
136
+ <button id=zIn title="Zoom in">+</button>
137
+ <span id=zPct>100%</span>
138
+ <button id=zFit title="Zoom to fit (Home)">Fit</button>
139
+ </div>
140
+ </div>
141
+ <aside id=panel></aside>
142
+ </main>
143
+ <datalist id=conntypes><option>moment</option><option>drag</option><option>shear</option><option>braced</option><option>pinned</option><option>fixed</option></datalist>
144
+ <datalist id=profiles></datalist>
145
+ <datalist id=details></datalist>
146
+ <div id=detailsModal><div class=mbackdrop id=detailsBackdrop></div>
147
+ <div class=mpanel><div class=mhead><b>Connection details</b><div style="display:flex;gap:8px;align-items:center"><button id=detailNew>+ New detail</button><button id=detailsClose>✕</button></div></div>
148
+ <div id=detailNewForm style="display:none;padding:14px;border-bottom:1px solid var(--line)">
149
+ <label class=elab for=dnName>Detail name</label>
150
+ <input id=dnName placeholder="e.g. 5-S504 or My moment conn." autocomplete=off>
151
+ <div id=dnDrop title="Paste or click to choose"><span id=dnDropTxt>Paste a screenshot (Ctrl+V) — or click to choose an image</span><img id=dnPrev alt=""></div>
152
+ <input id=dnFile type=file accept="image/*" hidden>
153
+ <div id=dnBtns><button id=dnCancel class=ghost>Cancel</button><button id=dnAdd>Add detail</button></div>
154
+ <div class=hint id=dnHint>For anything the auto‑detection missed. The image stays on your machine.</div>
155
+ </div>
156
+ <div class=mgrid id=detailsGrid></div></div></div>
157
+ <div id=framesModal><div class=mbackdrop id=framesBackdrop></div>
158
+ <div class=mpanel><div class=mhead><b>Moment frame schedule</b><button id=framesClose>✕</button></div>
159
+ <div id=framesGrid style="padding:14px;overflow:auto"></div></div></div>
160
+ <div id=rfiModal><div class=mbackdrop id=rfiBackdrop></div>
161
+ <div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
162
+ <div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
163
+ <div id=askAiModal><div class=mbackdrop id=askAiBackdrop></div>
164
+ <div class=mpanel style="width:min(560px,92vw)">
165
+ <div class=mhead><b>Ask the AI</b><button id=askAiClose>✕</button></div>
166
+ <div style="padding:14px;display:flex;flex-direction:column;gap:10px">
167
+ <textarea id=askAiText rows=4 placeholder="Describe the change — e.g. 'this bay is missing two W12x19 infills'" style="width:100%;resize:vertical;background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:8px;font:13px system-ui;min-height:72px"></textarea>
168
+ <div id=askAiDrop style="border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:72px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;text-align:center;color:var(--mut);cursor:pointer;padding:10px;font-size:12px">
169
+ <span id=askAiDropTxt>Paste or drop a screenshot (Ctrl+V) — optional</span>
170
+ </div>
171
+ <input id=askAiFile type=file accept="image/*" multiple hidden>
172
+ <div id=askAiThumbs style="display:flex;flex-wrap:wrap;gap:8px"></div>
173
+ <div style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
174
+ <span id=askAiStatus style="flex:1;font-size:12px;color:var(--mut)"></span>
175
+ <button id=askAiCancel class=ghost>Cancel</button>
176
+ <button id=askAiSend disabled>Send</button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ <div id=lightbox><div class=mbackdrop id=lbBackdrop></div>
182
+ <div class=lbpanel><div class=mhead><b id=lbCap></b><div style="display:flex;gap:10px;align-items:center"><span class=hint>scroll = zoom · drag = pan · dbl-click = reset</span><button id=lbClose>✕</button></div></div><div id=lbView><img id=lbImg></div></div></div>
183
+ <script>
184
+ const APP_ID = new URLSearchParams(location.search).get('app') || '';
185
+ let C, PAL, WT;
186
+ let lastLocalPut = 0; // timestamp of our own PUT — used to suppress the SSE echo
187
+ function showEmpty(icon, headline, bodyText, appIdText) {
188
+ const wrap = document.createElement('div');
189
+ wrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;text-align:center;color:#e7ecf3;font:13px system-ui,sans-serif;gap:10px;padding:24px;background:#070a0f';
190
+ const ic = document.createElement('div'); ic.style.cssText = 'font-size:28px;opacity:.5'; ic.textContent = icon;
191
+ const h = document.createElement('div'); h.style.cssText = 'font-size:15px;font-weight:600';
192
+ if (appIdText != null) { h.append(headline + ' '); const b = document.createElement('strong'); b.textContent = appIdText; h.append(b); }
193
+ else { h.textContent = headline; }
194
+ const body = document.createElement('div'); body.style.cssText = 'font-size:12.5px;color:#9aa5b6;max-width:340px;line-height:1.55'; body.textContent = bodyText;
195
+ wrap.append(ic, h, body);
196
+ document.body.replaceChildren(wrap);
197
+ }
198
+ async function boot() {
199
+ if (!APP_ID) { showEmpty('⊞', 'No workflow selected', 'Open a workflow in the FloLess canvas, then open the contract editor from that workflow’s node.'); return; }
200
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID));
201
+ if (!res.ok) {
202
+ if (res.status === 404) {
203
+ showEmpty('⊞', 'No takeoff for', 'Read a structural drawing first — ask your terminal AI to run the workflow, then open this editor again.', APP_ID);
204
+ } else {
205
+ showEmpty('⚠', 'Couldn’t load the contract', 'The server returned an error. Check that the FloLess server is running, then reopen the editor.');
206
+ }
207
+ return;
208
+ }
209
+ C = await res.json();
210
+ PAL = C.palette; WT = C.weights;
211
+ C.custom_details = C.custom_details || {};
212
+ C.profile_colors = C.profile_colors || {};
213
+ main();
214
+ // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
215
+ // We open our own EventSource to the same /api/events endpoint as the main app.
216
+ // Uses the same parse format: es.onmessage → JSON.parse(e.data) → {type, ...}.
217
+ if (typeof EventSource !== 'undefined') {
218
+ try {
219
+ const es = new EventSource('/api/events');
220
+ es.onmessage = (e) => {
221
+ let m;
222
+ try { m = JSON.parse(e.data); } catch { return; }
223
+ if (!m || typeof m !== 'object') return;
224
+ if (m.type !== 'contract-changed') return;
225
+ if (m.appId !== APP_ID) return;
226
+ // Suppress our own PUT echo (debounce: 2.5 s window)
227
+ if (Date.now() - lastLocalPut < 2500) return;
228
+ // External writeback (the AI wrote a new contract to the server) — the local
229
+ // localStorage draft is now STALE and would mask the server's version on reload
230
+ // (restoreSaved() overlays it). Drop it so any reload shows the AI's contract.
231
+ try { localStorage.removeItem('steeltakeoff:edits:v1:' + APP_ID); } catch (_) { /* ignore */ }
232
+ // Show a dismissible banner so the user can choose to reload into the new version.
233
+ showAiUpdateBanner();
234
+ };
235
+ es.onerror = () => {
236
+ // Transient drops (readyState CONNECTING) auto-reconnect — ignore. A CLOSED channel is
237
+ // terminal: live writeback detection is dead, so surface it (the banner's Reload re-opens
238
+ // the editor and its EventSource) instead of leaving the user on a silently-stale view.
239
+ if (es.readyState === EventSource.CLOSED) {
240
+ console.warn('steel-editor: SSE channel closed — live updates paused');
241
+ showAiUpdateBanner('Live updates disconnected — reload to resume change detection.');
242
+ }
243
+ };
244
+ } catch (_) {
245
+ // If EventSource construction itself throws (unusual), the Reload button still works
246
+ }
247
+ }
248
+ }
249
+
250
+ function showAiUpdateBanner(message) {
251
+ // Remove any existing banner before adding a new one (idempotent)
252
+ const existing = document.getElementById('aiUpdateBanner');
253
+ if (existing) existing.remove();
254
+
255
+ const bar = document.createElement('div');
256
+ bar.id = 'aiUpdateBanner';
257
+ bar.style.cssText = [
258
+ 'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:50',
259
+ 'display:flex', 'align-items:center', 'gap:10px',
260
+ 'padding:10px 16px',
261
+ 'background:#1e40af', 'color:#e0f2fe',
262
+ 'font:13px system-ui', 'border-top:1px solid #3b82f6',
263
+ 'box-shadow:0 -4px 18px rgba(0,0,0,.45)',
264
+ ].join(';');
265
+
266
+ const msg = document.createElement('span');
267
+ msg.style.cssText = 'flex:1';
268
+ msg.textContent = message || 'The AI updated this takeoff — reload to see the latest version.';
269
+
270
+ const reloadBtn = document.createElement('button');
271
+ reloadBtn.style.cssText = 'background:#3b82f6;color:#fff;border:1px solid #60a5fa;border-radius:6px;padding:5px 12px;cursor:pointer;font:13px system-ui';
272
+ reloadBtn.textContent = '↻ Reload';
273
+ reloadBtn.onclick = () => location.reload();
274
+
275
+ const dismissBtn = document.createElement('button');
276
+ dismissBtn.style.cssText = 'background:transparent;color:#93c5fd;border:1px solid #3b82f6;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui';
277
+ dismissBtn.textContent = '✕';
278
+ dismissBtn.title = 'Dismiss';
279
+ dismissBtn.onclick = () => bar.remove();
280
+
281
+ bar.appendChild(msg);
282
+ bar.appendChild(reloadBtn);
283
+ bar.appendChild(dismissBtn);
284
+ document.body.appendChild(bar);
285
+ }
286
+
287
+ function main() {
288
+ let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
289
+ let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
290
+ let selIds=new Set();
291
+ let undo=[], redo=[];
292
+ const byId=id=>P.members.find(m=>m.id===id);
293
+ const selArr=()=>P.members.filter(m=>selIds.has(m.id));
294
+ const numOrNull=v=>{const n=parseFloat(v);return (v===''||v==null||isNaN(n))?null:n;};
295
+ // elevation = canonical decimal INCHES. parseLen accepts 5.75 | 5 3/4 | 1'-0 1/4" | 16'-6"
296
+ function parseLen(str){if(str==null)return null;
297
+ let s=String(str).trim().replace(/[”″]/g,'"').replace(/[’′]/g,"'");
298
+ if(s==='')return null;let neg=false;if(s[0]==='-'){neg=true;s=s.slice(1).trim();}
299
+ let ft=0;const fm=s.match(/^([0-9]+(?:\.[0-9]+)?)\s*'/);
300
+ if(fm){ft=parseFloat(fm[1]);s=s.slice(fm[0].length).replace(/^[-\s]+/,'');}
301
+ s=s.replace(/"/g,'').trim();let inch=0;
302
+ if(s!==''){const m=s.match(/^([0-9]+(?:\.[0-9]+)?)?\s*([0-9]+)\/([0-9]+)$|^([0-9]+(?:\.[0-9]+)?)$/);
303
+ if(!m)return null;
304
+ if(m[4]!=null)inch=parseFloat(m[4]);else{if(m[1]!=null)inch+=parseFloat(m[1]);inch+=parseInt(m[2],10)/parseInt(m[3],10);}}
305
+ const t=ft*12+inch;return neg?-t:t;}
306
+ function fmtFtIn(v){if(v==null||isNaN(v))return '';const neg=v<0;let sx=Math.round(Math.abs(v)*16);
307
+ let ft=Math.floor(sx/192);sx-=ft*192;let inch=Math.floor(sx/16);let fr=sx-inch*16;
308
+ let num=fr,den=16;while(num>0&&num%2===0){num/=2;den/=2;}const fs=num>0?(' '+num+'/'+den):'';
309
+ const body=ft>0?(ft+"'-"+inch+fs+'"'):(inch+fs+'"');return (neg?'-':'')+body;}
310
+ const fmtDecIn=v=>(v==null||isNaN(v))?'—':((Math.round(v*100)/100)+'"');
311
+ function ensureMeta(m){if(!m.role)m.role='beam';
312
+ if(!m.ends){const c=/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':'';m.ends=[{tos:null,note:c,tosDef:true,detail:''},{tos:null,note:c,tosDef:true,detail:''}];}
313
+ if(!m.col)m.col={bos:null,tos:null,note:(/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':''),tosDef:true,detail:''};return m;}
314
+ // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
315
+ // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
316
+ let defaultTOS=198, addProfile='';
317
+ function syncDefaults(){for(const m of P.members){ensureMeta(m);
318
+ if(m.role==='column'){if(m.col.tosDef!==false&&defaultTOS!=null)m.col.tos=defaultTOS;if(m.col.bos==null)m.col.bos=0;}
319
+ else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
320
+ const autofillTOS=syncDefaults;
321
+ // profs is per-plan — computed in setPlan()
322
+ function snapshot(){return JSON.stringify(P.members);}
323
+ function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
324
+ function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();}
325
+ function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
326
+ function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
327
+ function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();}
328
+ // --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
329
+ const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
330
+ function dataSig(){return C.plans.map(p=>p.sheet+':'+((p.segments||[]).length)).join('|');} // ignore stored edits if the dataset changed
331
+ let saveT=null;
332
+ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el)return;el.classList.remove('dirty','ok','err');
333
+ if(state==='ok'){el.classList.add('ok');el.textContent=msg||'Saved ✓';}
334
+ else if(state==='dirty'){el.classList.add('dirty');el.textContent='Saving…';}
335
+ else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
336
+ else el.textContent=msg||'Auto-save on';}
337
+ function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
338
+ custom_details:C.custom_details, profile_colors:C.profile_colors,
339
+ plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
340
+ // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
341
+ // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
342
+ // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
343
+ async function persistServer(){try{
344
+ lastLocalPut = Date.now();
345
+ const res=await fetch('/api/contract/'+encodeURIComponent(APP_ID),{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(C)});
346
+ setSaved(res.ok?'ok':'err');
347
+ if(!res.ok)console.error('server save rejected ('+res.status+')',await res.text().catch(()=>'')); // 400 = schema reject: edits won't be what Approve bakes
348
+ }catch(e){setSaved('err');console.error('server save failed',e);}}
349
+ function scheduleSave(){setSaved('dirty');clearTimeout(saveT);saveT=setTimeout(()=>{persist();persistServer();},500);}
350
+ // Flush: cancel the pending debounce and PUT the current in-memory contract to the
351
+ // server right now. The parent (aware.js #contract-editor-approve) awaits this before
352
+ // POSTing approve, so Approve always bakes the latest edits — not a stale debounce
353
+ // snapshot. Throws on a non-2xx response so the parent can abort and show the warn toast.
354
+ window.flushContract = async () => {
355
+ clearTimeout(saveT);
356
+ lastLocalPut = Date.now();
357
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), {
358
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
359
+ });
360
+ setSaved(res.ok ? 'ok' : 'err');
361
+ if (!res.ok) throw new Error('save failed (' + res.status + ')');
362
+ };
363
+ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return false;const d=JSON.parse(raw);
364
+ if(d.sig!==dataSig())return false;const by={};(d.plans||[]).forEach(p=>by[p.sheet]=p);
365
+ if(d.custom_details)C.custom_details=d.custom_details;
366
+ if(d.profile_colors)C.profile_colors=d.profile_colors;
367
+ C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;p.autofilled=true;}});
368
+ if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
369
+ function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
370
+ function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
371
+ function len(a,b){return Math.hypot(a[0]-b[0],a[1]-b[1]);}
372
+ // --- geometric edit: extend/trim (snap nearest end to a target line) + split at a point ---
373
+ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y3=p3[1],x4=p4[0],y4=p4[1];
374
+ const den=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);if(Math.abs(den)<1e-9)return null; // parallel → no intersection
375
+ const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
376
+ function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
377
+ let t=((p[0]-a[0])*vx+(p[1]-a[1])*vy)/L2;t=Math.max(0,Math.min(1,t));return {pt:[a[0]+t*vx,a[1]+t*vy],t};}
378
+ function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
379
+ // nearest end → intersection: extends when the line is past the end, trims when it crosses the member. ponytail: keeps the larger piece on a deep overshoot — undo+drag if that's wrong.
380
+ function snapEnd(m,tA,tB){const I=lineX(m.wp[0],m.wp[1],tA,tB);if(!I)return false;
381
+ const pv=snapshot();const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(WT[m.profile]==null);pushUndo(pv);render();return true;}
382
+ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(JSON.stringify(m));
383
+ const a=m.wp[0].slice(),b=m.wp[1].slice(),q=pt.slice(),mk=()=>({tos:null,note:'',tosDef:true,detail:''});
384
+ m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
385
+ const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
386
+ if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
387
+ c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);geoMode=null;setGeo();pushUndo(pv);render();}
388
+ // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
389
+ function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
390
+ function dupScore(m){let s=0;if(WT[m.profile]!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
391
+ function redundantDups(){const g={};for(const m of P.members){(g[dupKey(m)]=g[dupKey(m)]||[]).push(m);}
392
+ const out=[];for(const k in g){const grp=g[k];if(grp.length<2)continue;
393
+ grp.sort((a,b)=>dupScore(b)-dupScore(a));for(let i=1;i<grp.length;i++)out.push(grp[i].id);} // keep [0], rest redundant
394
+ return out;}
395
+ const svg=document.getElementById('svg');
396
+ function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
397
+ // --- zoom + pan ---
398
+ const ZMIN=0.1, ZMAX=4;
399
+ let zoom=1; const stage=document.getElementById('stage');
400
+ function applyZoom(z,ax,ay){z=Math.max(ZMIN,Math.min(ZMAX,z));
401
+ const pt=(ax!=null)?toSvg({clientX:ax,clientY:ay}):null;
402
+ zoom=z;svg.setAttribute('width',EXTX*zoom);svg.setAttribute('height',EXTY*zoom);
403
+ if(pt){const m=svg.getScreenCTM();stage.scrollLeft+=(m.a*pt.x+m.e)-ax;stage.scrollTop+=(m.d*pt.y+m.f)-ay;}
404
+ document.getElementById('zRange').value=Math.round(zoom*100);
405
+ document.getElementById('zPct').textContent=Math.round(zoom*100)+'%';updateBadges();}
406
+ function zoomCentered(z){const r=stage.getBoundingClientRect();applyZoom(z,r.left+stage.clientWidth/2,r.top+stage.clientHeight/2);}
407
+ function fitToWindow(){applyZoom(Math.min(stage.clientWidth/EXTX,stage.clientHeight/EXTY)*0.98);}
408
+ // --- duplicate the selected member(s) in place (Ctrl+D) ---
409
+ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot(),ns=new Set();
410
+ let i=0;for(const s of arr){const id='m'+Date.now()+'_'+(i++);const c=JSON.parse(JSON.stringify(s));
411
+ c.id=id;c.wp=[[s.wp[0][0]+o,s.wp[0][1]+o],[s.wp[1][0]+o,s.wp[1][1]+o]];P.members.push(c);ns.add(id);}
412
+ selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
413
+ function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
414
+ function render(){
415
+ if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} // geo edit needs exactly one member
416
+ let s=`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`;
417
+ for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
418
+ for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
419
+ s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
420
+ {const hsel=selArr();if(hsel.length>=1&&hsel.length<=2){const HR=9/zoom;for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class=handle data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}}
421
+ if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
422
+ s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
423
+ if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
424
+ s+=`<rect class=dethot data-det="${esc(d.text)}" x="${d.disp[0]-w/2}" y="${d.disp[1]-9}" width="${w}" height="18" rx="3"><title>${esc(d.text)}</title></rect><text class=dettx x="${d.disp[0]}" y="${d.disp[1]+4}">${esc(d.text)}</text>`;}
425
+ // numbered badges on the selected members (1..N in selection order; coincident ones fan out so duplicates are countable)
426
+ // sized in 1/zoom units → constant on screen at any zoom (updateBadges keeps them in sync while zooming)
427
+ const selM=selArr();
428
+ if(selM.length>=2){const mid=m=>[(m.wp[0][0]+m.wp[1][0])/2,(m.wp[0][1]+m.wp[1][1])/2];const grp={};const R=12/zoom,F=13/zoom;
429
+ selM.forEach((m,idx)=>{const c=mid(m),k=Math.round(c[0]/8)+','+Math.round(c[1]/8);(grp[k]=grp[k]||[]).push({idx,c});});
430
+ for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
431
+ s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
432
+ svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup();
433
+ }
434
+ function updDup(){const n=redundantDups().length;
435
+ document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
436
+ const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
437
+ b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Re-check duplicates';}
438
+ // keep selection-number badges + endpoint handles a constant size on screen at any zoom
439
+ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
440
+ svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
441
+ svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
442
+ svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',9/zoom));}
443
+ function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
444
+ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
445
+ if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
446
+ function stats(){
447
+ document.getElementById('mc').textContent=P.members.length;
448
+ let w=0,rfi=0; for(const m of P.members){const wpf=WT[m.profile]; if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
449
+ document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
450
+ }
451
+ function panel(){
452
+ const p=document.getElementById('panel');
453
+ if(mode==='add'){
454
+ p.innerHTML=`<span class=badge>Add member</span>
455
+ <div class=sect>Current profile</div>
456
+ <input id=addProf class=combo data-src=profiles placeholder="type or pick (e.g. W16X26)" value="${esc(addProfile)}" autocomplete=off>
457
+ <div class=hint id=pickHint style="margin-top:4px;font-style:italic">or click a label in the drawing</div>
458
+ <div class=divrow><hr></div>
459
+ <div class=sect>Draw a member</div>
460
+ <div class=hint>✏️ Drag two points on the canvas — or — click a detected segment. New members take the current profile. <b>Shift</b> = straight, <b>Esc</b> = exit.</div>`;
461
+ const ap=document.getElementById('addProf');
462
+ ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
463
+ return;}
464
+ const arr=selArr();
465
+ if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" title="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
466
+ '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
467
+ '<div class=hint style="margin:0 0 6px;font-size:11px">Level <b>'+esc(fmtFtIn(defaultTOS))+'</b> ('+esc(P.sheet)+'). '+((P.tos_callouts&&P.tos_callouts.length)?'Per-zone T.O. STEEL callouts from the drawing applied to each member; ':'')+'ends ticked <b>default</b> follow the level.</div>'+
468
+ '<div class=elab>Default TOS</div><input id=defTos inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="'+esc(fmtFtIn(defaultTOS))+'"><span class=edec>'+esc(fmtDecIn(defaultTOS))+'</span>'+
469
+ '<button class="ghostw f" id=applyTos>Reset all ends to default</button></div>';
470
+ document.getElementById('defTos').onchange=e=>{defaultTOS=parseLen(e.target.value);P.default_tos=defaultTOS;syncDefaults();scheduleSave();render();};
471
+ document.getElementById('applyTos').onclick=()=>edit(()=>{for(const m of P.members){ensureMeta(m);if(m.role==='column'){m.col.tosDef=true;m.col.tos=defaultTOS;}else for(const en of m.ends){en.tosDef=true;en.tos=defaultTOS;}}});
472
+ document.querySelectorAll('.swc').forEach(c=>c.onchange=e=>{C.profile_colors[c.dataset.prof]=e.target.value;scheduleSave();render();}); // recolour a profile (applies everywhere it's drawn)
473
+ const _rc=document.getElementById('resetCols');if(_rc)_rc.onclick=()=>{C.profile_colors={};scheduleSave();render();};
474
+ return;}
475
+ if(arr.length>1){
476
+ const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
477
+ const shared=arr.every(m=>m.profile===arr[0].profile)?arr[0].profile:null;
478
+ const dupSet=new Set(redundantDups());const dupSel=arr.filter(m=>dupSet.has(m.id)).length;
479
+ p.innerHTML=`<h3>${arr.length} members selected</h3>
480
+ <div class=row><label>Profile</label><input id=pf class=combo data-src=profiles placeholder="${shared==null?'— mixed —':''}" value="${shared==null?'':esc(shared)}" autocomplete=off></div>
481
+ <div class="row hint">Total length <b>${totalL} ft</b>${dupSel?` · <span style="color:#fca5a5">${dupSel} duplicate${dupSel>1?'s':''}</span> (overlap a kept member — deleting these dedupes)`:''}</div>
482
+ <div class=row><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
483
+ document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(WT[v]==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
484
+ document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
485
+ return;}
486
+ const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column');
487
+ const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
488
+ const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
489
+ const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
490
+ const tosFld=(id,label,o)=>{const def=o.tosDef!==false,v=def?defaultTOS:o.tos;
491
+ return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"${def?' disabled':''}><span class=edec>${esc(fmtDecIn(v))}</span>`;};
492
+ const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
493
+ const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost title="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" title="Pick a detail callout from the drawing">⌖</button></div>`;};
494
+ const elev = col
495
+ ? `<div class="sect f">Elevation</div>${ftFld('bos','Bottom (BOS)',m.col.bos)}${tosFld('tosC','Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
496
+ : `<div class="sect f">Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
497
+ <div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
498
+ <div class=sect>End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
499
+ p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
500
+ <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
501
+ <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
502
+ <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
503
+ ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
504
+ ${elev}
505
+ <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
506
+ <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim</button><button id=geoSplit class="${geoMode==='split'?'on':''}">Split</button></div>
507
+ <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — another member or a grey segment. The nearest end of this member snaps to where the two lines meet (extends if short, trims if it overshoots).':geoMode==='split'?'Click a <b>point on this member</b> to cut it into two members.':'<b>Extend/Trim</b> (E) an end to meet another line · <b>Split</b> (S) at a point. <b>Esc</b> cancels.'}</div>
508
+ <div class="row f"><button class=danger id=del>Delete member</button></div>`;
509
+ document.getElementById('pf').onchange=e=>edit(()=>{const v=e.target.value.toUpperCase().replace(/ /g,'');m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});
510
+ document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
511
+ document.getElementById('rBeam').onclick=()=>{if(col)edit(()=>{m.role='beam';});};
512
+ document.getElementById('rCol').onclick=()=>{if(!col)edit(()=>{m.role='column';});};
513
+ document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}}));
514
+ const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
515
+ if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
516
+ if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
517
+ const wireTos=(id,o)=>{const ck=document.getElementById(id+'_ck');if(ck)ck.onchange=e=>edit(()=>{o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;});
518
+ const inp=document.getElementById(id);if(inp)inp.onchange=e=>edit(()=>{o.tos=parseLen(e.target.value);o.tosDef=false;});};
519
+ const wireDet=(id,o)=>{const i=document.getElementById(id);if(i)i.onchange=e=>edit(()=>{o.detail=e.target.value.trim();});
520
+ const pk=document.getElementById(id+'_pk');if(pk)pk.onclick=()=>{if(picking&&pickKind==='detail'&&pickEnd===o){picking=false;pickEnd=null;}else{picking=true;pickKind='detail';pickEnd=o;}render();};
521
+ const op=document.getElementById(id+'_open');if(op)op.onclick=()=>openPreview(o.detail);};
522
+ if(col){
523
+ document.getElementById('bos').onchange=e=>edit(()=>{m.col.bos=parseLen(e.target.value);});
524
+ wireTos('tosC',m.col);
525
+ document.getElementById('ntC').onchange=e=>edit(()=>{m.col.note=e.target.value;});
526
+ wireDet('dtC',m.col);
527
+ }else{
528
+ wireTos('tosA',m.ends[0]);
529
+ document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
530
+ wireDet('dtA',m.ends[0]);
531
+ wireTos('tosB',m.ends[1]);
532
+ document.getElementById('ntB').onchange=e=>edit(()=>{m.ends[1].note=e.target.value;});
533
+ wireDet('dtB',m.ends[1]);
534
+ 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};});
535
+ }
536
+ document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
537
+ }
538
+ function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const P=addProfile;const id='m'+Date.now();const pv=snapshot();
539
+ P.members.push(ensureMeta({id,profile:P,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[P]==null)}));
540
+ if(P&&!profs.includes(P)){profs.push(P);profs.sort();}pushUndo(pv);render();}
541
+ // --- marquee hit-test (crossing selection): does segment ab touch rect [x0,y0,x1,y1]? ---
542
+ function inRect(p,r){return p[0]>=r[0]&&p[0]<=r[2]&&p[1]>=r[1]&&p[1]<=r[3];}
543
+ function ccw(a,b,c){return (c[1]-a[1])*(b[0]-a[0])-(b[1]-a[1])*(c[0]-a[0]);}
544
+ function segSeg(a,b,c,d){return (ccw(a,c,d)>0)!==(ccw(b,c,d)>0) && (ccw(a,b,c)>0)!==(ccw(a,b,d)>0);}
545
+ function rectHit(p0,p1,r){
546
+ if(inRect(p0,r)||inRect(p1,r))return true;
547
+ const c=[[r[0],r[1]],[r[2],r[1]],[r[2],r[3]],[r[0],r[3]]];
548
+ for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
549
+ return false;}
550
+ // --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections); Alt = off ---
551
+ const SNAP_PX=9; let snapPts=[];
552
+ function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
553
+ snapPts=[];for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);}for(const s of P.segments){snapPts.push(s.a,s.b);}}
554
+ function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
555
+ for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
556
+ if(bp)return {x:bp[0],y:bp[1],hit:true};
557
+ let sx=x,sy=y,bx=tol,by=tol;
558
+ 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];}}
559
+ return {x:sx,y:sy,hit:false};}
560
+ 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);}
561
+ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
562
+ // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
563
+ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
564
+ if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
565
+ if(geoMode==='split'){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
566
+ const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
567
+ if(d>=16/zoom||raw.t<=0.03||raw.t>=0.97){geoMode=null;setGeo();snapClear();render();return;}
568
+ let cut=raw.pt; // snap the cut to a nearby endpoint/grid, kept ON the member
569
+ 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;}}
570
+ snapClear();doSplit(m,cut);return;}
571
+ let tgt=null; // extend/trim → pick a target line
572
+ if(t.classList.contains('member')&&t.dataset.id!==id){const tm=byId(t.dataset.id);if(tm)tgt=[tm.wp[0],tm.wp[1]];}
573
+ else if(t.classList.contains('seg')){const sg=P.segments.find(s=>s.id===t.dataset.seg);if(sg)tgt=[sg.a,sg.b];}
574
+ if(tgt){geoMode=null;setGeo();snapEnd(m,tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
575
+ return;}
576
+ if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
577
+ drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
578
+ if(t.classList.contains('lblhot')){const prof=t.dataset.prof;
579
+ if(picking&&pickKind==='profile'&&selIds.size===1){const id=[...selIds][0];picking=false;edit(()=>{const m=byId(id);m.profile=prof;m.rfi=(WT[prof]==null);if(!profs.includes(prof)){profs.push(prof);profs.sort();}});return;}
580
+ if(mode==='add'){addProfile=prof;const hi=document.getElementById('addProf');if(hi)hi.value=addProfile;const ph=document.getElementById('pickHint');if(ph){ph.classList.add('pick');setTimeout(()=>ph&&ph.classList.remove('pick'),450);}return;}
581
+ return;}
582
+ if(t.classList.contains('dethot')){const det=t.dataset.det;
583
+ if(picking&&pickKind==='detail'&&pickEnd){const o=pickEnd;picking=false;pickEnd=null;edit(()=>{o.detail=det;});}return;}
584
+ if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
585
+ if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
586
+ if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id;
587
+ if(e.ctrlKey||e.metaKey){selIds.has(id)?selIds.delete(id):selIds.add(id);render();return;}
588
+ if(!selIds.has(id)){selIds=new Set([id]);render();}
589
+ const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
590
+ buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
591
+ drag={type:'move',start:[p.x,p.y],items,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
592
+ if((t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e);
593
+ const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
594
+ drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
595
+ svg.addEventListener('pointermove',e=>{
596
+ 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]);
597
+ const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
598
+ 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;}
599
+ if(!drag)return;const p=toSvg(e);
600
+ if(drag.type==='draw'){let x=p.x,y=p.y;
601
+ if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
602
+ else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
603
+ drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
604
+ if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
605
+ 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;}
606
+ if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
607
+ if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
608
+ else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so its nearest endpoint lands on a snap target
609
+ for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
610
+ 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};}}}
611
+ if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1]);}else snapClear();}
612
+ else snapClear();
613
+ for(const it of drag.items){const m=byId(it.id);if(!m)continue;
614
+ m.wp[0]=[it.o0[0]+dx,it.o0[1]+dy];m.wp[1]=[it.o1[0]+dx,it.o1[1]+dy];updateLine(m);}
615
+ if(selIds.size<=2)for(const m of selArr())updateHandles(m);return;}
616
+ // endpoint
617
+ const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
618
+ if(e.shiftKey){if(Math.abs(x-drag.anchor[0])>=Math.abs(y-drag.anchor[1]))y=drag.anchor[1];else x=drag.anchor[0];snapClear();}
619
+ else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
620
+ m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
621
+ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
622
+ if(drag.type==='draw'){const a=[drag.x0,drag.y0],b=drag.cur||a;drag.line.remove();
623
+ if(len(a,b)>6){const dx=Math.abs(b[0]-a[0]),dy=Math.abs(b[1]-a[1]),o=dx>3*dy?'H':(dy>3*dx?'V':'D');
624
+ const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
625
+ if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
626
+ drag=null;render();return;}
627
+ if(drag.type==='marquee'){if(!drag.add)selIds.clear();
628
+ const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2))for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
629
+ drag.rect.remove();drag=null;render();return;}
630
+ if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
631
+ function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
632
+ document.getElementById('mAdd').onclick=()=>{mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();};
633
+ document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
634
+ if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
635
+ setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
636
+ if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
637
+ document.getElementById('undoB').onclick=doUndo;
638
+ document.getElementById('redoB').onclick=doRedo;
639
+ addEventListener('keydown',e=>{
640
+ const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
641
+ if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
642
+ if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
643
+ if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
644
+ if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
645
+ if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
646
+ if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
647
+ if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
648
+ if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
649
+ if(!inForm&&selIds.size===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
650
+ if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;}
651
+ if(kk==='s'){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}}
652
+ if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
653
+ if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
654
+ else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
655
+ else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
656
+ else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}});
657
+ // zoom bar
658
+ document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
659
+ document.getElementById('zOut').onclick=()=>zoomCentered(zoom/1.25);
660
+ document.getElementById('zFit').onclick=fitToWindow;
661
+ document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
662
+ // Ctrl+wheel = zoom at cursor
663
+ stage.addEventListener('wheel',e=>{if(e.ctrlKey){e.preventDefault();applyZoom(zoom*Math.exp(-e.deltaY*0.0015),e.clientX,e.clientY);}},{passive:false});
664
+ // middle-button drag = pan
665
+ stage.addEventListener('mousedown',e=>{if(e.button===1)e.preventDefault();});
666
+ let pan=null;
667
+ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault();
668
+ pan={x:e.clientX,y:e.clientY,sl:stage.scrollLeft,st:stage.scrollTop};stage.setPointerCapture(e.pointerId);stage.style.cursor='grabbing';});
669
+ stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
670
+ stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
671
+ document.getElementById('exp').onclick=()=>{const out={pt_per_ft:FT,elev_units:'inch',members:P.members.map(m=>{ensureMeta(m);return {profile:m.profile,workpoint:m.wp,role:m.role,rfi:WT[m.profile]==null,
672
+ length_ft:+(len(m.wp[0],m.wp[1])/FT).toFixed(1),weight_plf:WT[m.profile],
673
+ ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
674
+ const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
675
+ const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
676
+ // --- details gallery (preview = crop of detail N from its sheet; .preview base64 when available) ---
677
+ // per-detail thumbnail = the bubble's cell cropped client-side from the full-sheet preview (no extra crops stored)
678
+ const THUMBS={};
679
+ function detThumb(text,imgEl){const key=(sheetOf(text)||'')+'|'+(numOf(text)||'');
680
+ if(THUMBS[key]){imgEl.src=THUMBS[key];return;}
681
+ const b64=previewFor(text),b=bubbleFor(text);if(!b64||!b)return;
682
+ const im=new Image();im.onload=()=>{const W=im.naturalWidth,H=im.naturalHeight;
683
+ const cw=Math.round(W*0.27),ch=Math.round(H*0.30); // ~one detail cell; bubble sits low-right of it
684
+ let left=Math.round(b[0]*W-cw*0.82),top=Math.round(b[1]*H-ch*0.88);
685
+ left=Math.max(0,Math.min(W-cw,left));top=Math.max(0,Math.min(H-ch,top));
686
+ const cv=document.createElement('canvas');cv.width=cw;cv.height=ch;
687
+ cv.getContext('2d').drawImage(im,left,top,cw,ch,0,0,cw,ch);
688
+ try{const u=cv.toDataURL('image/jpeg',0.85);THUMBS[key]=u;imgEl.src=u;}catch(_){}};
689
+ im.src='data:image/jpeg;base64,'+b64;}
690
+ // rename a detail EVERYWHERE — across all plans' callout lists, member end/col refs, and the custom store
691
+ function applyDetailRename(old,nw){nw=(nw||'').trim();if(!nw||nw===old)return false;
692
+ for(const pl of C.plans){
693
+ if(pl.details)for(const d of pl.details)if(d.text===old)d.text=nw;
694
+ for(const m of pl.members){if(m.ends)for(const en of m.ends)if(en.detail===old)en.detail=nw;if(m.col&&m.col.detail===old)m.col.detail=nw;}}
695
+ if(C.custom_details[old]!=null){C.custom_details[nw]=C.custom_details[old];delete C.custom_details[old];}
696
+ scheduleSave();return true;}
697
+ function detTexts(){const seen=new Set(),out=[];
698
+ for(const d of (P.details||[])) if(!seen.has(d.text)){seen.add(d.text);out.push(d.text);}
699
+ for(const n of Object.keys(C.custom_details)) if(!seen.has(n)){seen.add(n);out.push(n);}
700
+ return out.sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));}
701
+ function openDetails(){const g=document.getElementById('detailsGrid');const uniq=detTexts();
702
+ g.innerHTML=uniq.length?uniq.map(t=>{const custom=C.custom_details[t]!=null;const sheet=custom?'':(sheetOf(t)||'');const pv=previewFor(t),b=custom?null:bubbleFor(t);
703
+ const prev=pv?(b?`<img class=dthumb data-t="${esc(t)}" alt="${esc(t)}">`:`<img src="data:image/jpeg;base64,${pv}" alt="${esc(t)}">`):`<div class=dprev>Preview pending<br>from sheet ${esc(sheet)}</div>`;
704
+ const sub=custom?'Custom detail':('Sheet '+esc(sheet)+(b?' · detail '+esc(numOf(t)):''));
705
+ return `<div class=dcard><div class=dprevwrap${pv?` data-det="${esc(t)}"`:''}>${prev}</div><div class=dmeta><input class=dname data-old="${esc(t)}" value="${esc(t)}" title="Rename (updates every plan)" autocomplete=off><div class=ds>${sub}${custom?` · <a href="#" class=ddel data-del="${esc(t)}">delete</a>`:''}</div></div></div>`;}).join('')
706
+ :'<div class=hint>No detail callouts detected. Use + New detail to add your own.</div>';
707
+ g.querySelectorAll('img.dthumb').forEach(im=>detThumb(im.dataset.t,im));
708
+ g.querySelectorAll('.dprevwrap[data-det]').forEach(c=>c.onclick=()=>openPreview(c.dataset.det));
709
+ g.querySelectorAll('input.dname').forEach(i=>i.onchange=()=>{if(applyDetailRename(i.dataset.old,i.value)){render();openDetails();}else i.value=i.dataset.old;});
710
+ g.querySelectorAll('a.ddel').forEach(a=>a.onclick=e=>{e.preventDefault();delete C.custom_details[a.dataset.del];scheduleSave();render();openDetails();});
711
+ document.getElementById('detailsBtn').textContent='Details ('+uniq.length+')';
712
+ document.getElementById('detailsModal').style.display='flex';}
713
+ function closeDetails(){document.getElementById('detailsModal').style.display='none';showDnForm(false);}
714
+ function detailsOpen(){return document.getElementById('detailsModal').style.display==='flex';}
715
+ document.getElementById('detailsBtn').onclick=openDetails;
716
+ document.getElementById('detailsClose').onclick=closeDetails;
717
+ document.getElementById('detailsBackdrop').onclick=closeDetails;
718
+ // --- create your own detail (name + screenshot) — for anything the auto-detect missed ---
719
+ function readImageCompressed(file,cb){const r=new FileReader();r.onload=()=>{const im=new Image();im.onload=()=>{
720
+ const max=1400;let w=im.naturalWidth,h=im.naturalHeight;const sc=Math.min(1,max/Math.max(w,h));w=Math.max(1,Math.round(w*sc));h=Math.max(1,Math.round(h*sc));
721
+ const cv=document.createElement('canvas');cv.width=w;cv.height=h;cv.getContext('2d').drawImage(im,0,0,w,h);
722
+ cb(cv.toDataURL('image/jpeg',0.82).split(',')[1]);};im.onerror=()=>cb(null);im.src=r.result;};r.readAsDataURL(file);}
723
+ const dnForm=document.getElementById('detailNewForm');let dnPending=null;
724
+ function dnHint(t){document.getElementById('dnHint').textContent=t;}
725
+ function dnSetImage(file){if(!file)return;dnHint('Processing image…');
726
+ readImageCompressed(file,b64=>{if(!b64){dnHint('Could not read that image — try another.');return;}
727
+ dnPending=b64;const pv=document.getElementById('dnPrev');pv.src='data:image/jpeg;base64,'+b64;pv.style.display='block';
728
+ document.getElementById('dnDropTxt').textContent='Image ready — paste/choose another to replace';
729
+ document.getElementById('dnDrop').classList.add('has');dnHint('Looks good — name it and Add detail.');});}
730
+ function showDnForm(s){dnForm.style.display=s?'block':'none';dnPending=null;
731
+ const pv=document.getElementById('dnPrev');if(pv){pv.src='';pv.style.display='none';}
732
+ const dt=document.getElementById('dnDropTxt');if(dt)dt.textContent='Paste a screenshot (Ctrl+V) — or click to choose an image';
733
+ document.getElementById('dnDrop').classList.remove('has');
734
+ if(s){document.getElementById('dnName').value='';document.getElementById('dnFile').value='';dnHint('For anything the auto‑detection missed. The image stays on your machine.');document.getElementById('dnName').focus();}}
735
+ document.getElementById('detailNew').onclick=()=>showDnForm(dnForm.style.display==='none');
736
+ document.getElementById('dnCancel').onclick=()=>showDnForm(false);
737
+ document.getElementById('dnDrop').onclick=()=>document.getElementById('dnFile').click();
738
+ document.getElementById('dnFile').onchange=e=>dnSetImage(e.target.files[0]);
739
+ document.addEventListener('paste',e=>{if(dnForm.style.display==='none')return;const items=(e.clipboardData||{}).items||[];
740
+ for(const it of items){if(it.type&&it.type.indexOf('image')===0){const f=it.getAsFile();if(f){e.preventDefault();dnSetImage(f);return;}}}}); // paste a screenshot straight in
741
+ document.getElementById('dnAdd').onclick=()=>{const name=document.getElementById('dnName').value.trim();
742
+ if(!name){dnHint('Please enter a name.');return;}
743
+ if(C.custom_details[name]!=null||(P.details||[]).some(d=>d.text===name)){dnHint('That name already exists — pick another.');return;}
744
+ if(!dnPending){dnHint('Paste or choose an image first.');return;}
745
+ C.custom_details[name]=dnPending;scheduleSave();showDnForm(false);render();openDetails();};
746
+ // --- moment frame schedule (read from the Frame Elevations S-301/302/303) ---
747
+ function openFrames(){const g=document.getElementById('framesGrid');const fr=C.moment_frames||[];
748
+ g.innerHTML=fr.length?('<div class=hint style="margin-bottom:10px">From the Frame Elevations (S-301/302/303). Select an MF member and pick its girder size from the Profile list — roof girders for the Roof plan, 2nd-floor girders for the Second-Floor plan.</div><table class=ftab><thead><tr><th>Grid</th><th>Span</th><th>Roof girder</th><th>2nd floor</th><th>Columns</th></tr></thead><tbody>'+
749
+ fr.map(f=>`<tr><td class=gcell>${esc(f.grid||'?')}</td><td>${esc((f.span||[]).join('–'))}</td><td>${esc((f.roof||[]).join(', ')||'—')}</td><td>${esc((f.second||[]).join(', ')||'—')}</td><td>${esc((f.columns||[]).join(', ')||'—')}</td></tr>`).join('')+'</tbody></table>')
750
+ :'<div class=hint>No moment-frame schedule loaded.</div>';
751
+ document.getElementById('framesModal').style.display='flex';}
752
+ function closeFrames(){document.getElementById('framesModal').style.display='none';}
753
+ function framesOpen(){return document.getElementById('framesModal').style.display==='flex';}
754
+ document.getElementById('framesBtn').onclick=openFrames;
755
+ document.getElementById('framesClose').onclick=closeFrames;
756
+ document.getElementById('framesBackdrop').onclick=closeFrames;
757
+ // --- RFI list: every unresolved member (no AISC size) with the reason, editable + locatable ---
758
+ function rfiList(){return P.members.filter(m=>WT[m.profile]==null);}
759
+ function rfiReason(m){if(!m.profile)return 'No profile assigned';
760
+ if(/^MF/i.test(m.profile))return 'Moment-frame mark — pick a girder (see Frames)';
761
+ if(/^BF/i.test(m.profile))return 'Brace-frame mark — pick a section';
762
+ return 'Size not in the AISC table — check the designation';}
763
+ function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
764
+ applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
765
+ const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
766
+ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
767
+ g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
768
+ list.map((m,i)=>{ensureMeta(m);const L=(len(m.wp[0],m.wp[1])/FT).toFixed(1);
769
+ return `<tr><td><span class=rfichip>${i+1}</span></td>
770
+ <td><input class=combo data-src=profiles data-mid="${esc(m.id)}" value="${esc(m.profile||'')}" placeholder="e.g. W16X26" autocomplete=off style="width:124px"></td>
771
+ <td>${m.role==='column'?'Column':'Beam'}</td><td>${L} ft</td><td class=rea>${esc(rfiReason(m))}</td>
772
+ <td><button class=ghost data-loc="${esc(m.id)}">Locate</button></td></tr>`;}).join('')+'</tbody></table>')
773
+ :'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
774
+ g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
775
+ const v=e.target.value.toUpperCase().replace(/ /g,'');edit(()=>{m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});openRFI();});
776
+ g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
777
+ document.getElementById('rfiModal').style.display='flex';}
778
+ function closeRFI(){document.getElementById('rfiModal').style.display='none';}
779
+ function rfiOpen(){return document.getElementById('rfiModal').style.display==='flex';}
780
+ document.getElementById('rfiStat').onclick=openRFI;
781
+ document.getElementById('rfiClose').onclick=closeRFI;
782
+ document.getElementById('rfiBackdrop').onclick=closeRFI;
783
+ // --- detail preview lightbox (click a card / an end's ⤢ to enlarge) ---
784
+ const SHEET_PREV=C.sheet_previews||{}, DBUB=C.detail_bubbles||{};
785
+ function sheetOf(text){const m=/(S-?\w+)/.exec(String(text||'').replace(/^\w+-/,''));if(!m)return null;const s=m[1].toUpperCase();return s.startsWith('S-')?s:'S-'+s.slice(1);}
786
+ function numOf(text){const m=/^([0-9A-Za-z]+)-/.exec(String(text||''));return m?m[1].toUpperCase():null;} // leading token = detail number
787
+ function previewFor(text){if(C.custom_details[text]!=null)return C.custom_details[text];const s=sheetOf(text);return s?SHEET_PREV[s]:null;}
788
+ function bubbleFor(text){const s=sheetOf(text),n=numOf(text);if(!s||!n||!DBUB[s])return null;
789
+ if(DBUB[s][n])return DBUB[s][n];const base=n.replace(/[A-Z]+$/,'');return (base&&DBUB[s][base])?DBUB[s][base]:null;} // 11A/11B → bubble 11
790
+ function lbFocus(fx,fy,scale){const im=document.getElementById('lbImg');const w=im.clientWidth,h=im.clientHeight;
791
+ const ty=Math.max(0.02,fy-0.05);lbS=scale;lbX=-(fx-0.5)*w*lbS;lbY=-(ty-0.5)*h*lbS;lbApply();} // centre the detail bubble (bias up to its body)
792
+ function openPreview(text){const img=previewFor(text);if(!img)return;const custom=C.custom_details[text]!=null;const b=custom?null:bubbleFor(text),n=numOf(text);
793
+ const li=document.getElementById('lbImg');const init=()=>{li.onload=null;b?lbFocus(b[0],b[1],3):lbReset();};
794
+ li.onload=init;li.src='data:image/jpeg;base64,'+img;
795
+ document.getElementById('lbCap').textContent=custom?('Custom · '+text):(sheetOf(text)+(n?' · detail '+n:'')+(b?'':' (numbered on the sheet)'));
796
+ document.getElementById('lightbox').style.display='flex';if(li.complete)init();}
797
+ function closeLightbox(){document.getElementById('lightbox').style.display='none';document.getElementById('lbImg').src='';}
798
+ function lightboxOpen(){return document.getElementById('lightbox').style.display==='flex';}
799
+ document.getElementById('lbClose').onclick=closeLightbox;
800
+ document.getElementById('lbBackdrop').onclick=closeLightbox;
801
+ // pan + zoom the detail image (same feel as the canvas: scroll=zoom at cursor, drag=pan)
802
+ let lbS=1,lbX=0,lbY=0,lbDrag=null;
803
+ function lbApply(){document.getElementById('lbImg').style.transform=`translate(-50%,-50%) translate(${lbX}px,${lbY}px) scale(${lbS})`;}
804
+ function lbReset(){lbS=1;lbX=0;lbY=0;lbApply();}
805
+ const lbView=document.getElementById('lbView');
806
+ lbView.addEventListener('wheel',e=>{e.preventDefault();const r=lbView.getBoundingClientRect();const cx=e.clientX-r.left-r.width/2,cy=e.clientY-r.top-r.height/2;
807
+ const f=Math.exp(-e.deltaY*0.0015),ns=Math.max(1,Math.min(8,lbS*f)),k=ns/lbS;lbX=cx-(cx-lbX)*k;lbY=cy-(cy-lbY)*k;lbS=ns;lbApply();},{passive:false});
808
+ lbView.addEventListener('pointerdown',e=>{lbDrag={x:e.clientX,y:e.clientY,ox:lbX,oy:lbY};lbView.classList.add('drag');lbView.setPointerCapture(e.pointerId);});
809
+ lbView.addEventListener('pointermove',e=>{if(!lbDrag)return;lbX=lbDrag.ox+(e.clientX-lbDrag.x);lbY=lbDrag.oy+(e.clientY-lbDrag.y);lbApply();});
810
+ lbView.addEventListener('pointerup',()=>{lbDrag=null;lbView.classList.remove('drag');});
811
+ lbView.addEventListener('dblclick',lbReset);
812
+ // --- custom dark combobox (replaces the native white datalist dropdown) ---
813
+ const COMBO_SRC={
814
+ profiles:()=>profs,
815
+ conntypes:()=>['moment','drag','shear','braced','pinned','fixed'],
816
+ details:()=>{const s=new Set();(P.details||[]).forEach(d=>s.add(d.text));Object.keys(C.custom_details).forEach(n=>s.add(n));return [...s].sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));}
817
+ };
818
+ const comboPop=document.createElement('div');comboPop.id='comboPop';document.body.appendChild(comboPop);
819
+ let comboInput=null,comboIdx=-1;
820
+ function showCombo(inp){const src=COMBO_SRC[inp.dataset.src];if(!src)return;comboInput=inp;
821
+ const q=(inp.value||'').trim().toUpperCase();const opts=src().filter(o=>String(o).toUpperCase().includes(q));
822
+ if(!opts.length){comboPop.style.display='none';return;}
823
+ comboPop.innerHTML=opts.map(o=>`<div class=opt data-v="${esc(o)}">${esc(o)}</div>`).join('');
824
+ const r=inp.getBoundingClientRect();comboPop.style.left=Math.round(r.left)+'px';comboPop.style.top=Math.round(r.bottom+2)+'px';comboPop.style.minWidth=Math.round(r.width)+'px';
825
+ comboPop.style.display='block';comboIdx=-1;}
826
+ function hideCombo(){comboPop.style.display='none';comboInput=null;comboIdx=-1;}
827
+ comboPop.addEventListener('mousedown',e=>{const o=e.target.closest('.opt');if(!o||!comboInput)return;e.preventDefault();
828
+ comboInput.value=o.dataset.v;comboInput.dispatchEvent(new Event('input'));comboInput.dispatchEvent(new Event('change'));hideCombo();});
829
+ document.addEventListener('focusin',e=>{if(e.target.classList&&e.target.classList.contains('combo'))showCombo(e.target);});
830
+ document.addEventListener('input',e=>{if(e.target===comboInput)showCombo(e.target);});
831
+ document.addEventListener('focusout',e=>{if(e.target===comboInput)setTimeout(hideCombo,150);});
832
+ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==='none')return;
833
+ const opts=[...comboPop.querySelectorAll('.opt')];if(!opts.length)return;
834
+ if(e.key==='ArrowDown')comboIdx=Math.min(opts.length-1,comboIdx+1);
835
+ else if(e.key==='ArrowUp')comboIdx=Math.max(0,comboIdx-1);
836
+ else if(e.key==='Enter'){if(comboIdx>=0){comboInput.value=opts[comboIdx].dataset.v;comboInput.dispatchEvent(new Event('input'));comboInput.dispatchEvent(new Event('change'));}hideCombo();e.preventDefault();e.stopPropagation();return;}
837
+ else if(e.key==='Escape'){hideCombo();e.stopPropagation();return;}
838
+ else return;
839
+ e.preventDefault();e.stopPropagation();opts.forEach((o,i)=>o.classList.toggle('active',i===comboIdx));if(comboIdx>=0)opts[comboIdx].scrollIntoView({block:'nearest'});},true);
840
+ // --- multi-plan: switch between all steel plan views ---
841
+ function setPlan(i){C.active=i;P=C.plans[i];
842
+ X0=P.clip[0];Y0=P.clip[1];X1=P.clip[2];Y1=P.clip[3];FT=P.pt_per_ft;RB64=P.raster_b64;EXTX=X1-X0;EXTY=Y1-Y0;
843
+ svg.setAttribute('viewBox',`${X0} ${Y0} ${X1-X0} ${Y1-Y0}`);
844
+ P.members.forEach(ensureMeta);
845
+ profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
846
+ undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
847
+ selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
848
+ defaultTOS=(P.default_tos!=null?P.default_tos:198);
849
+ if(!P.autofilled){autofillTOS();P.autofilled=true;}
850
+ const ds=new Set([...(P.details||[]).map(d=>d.text),...Object.keys(C.custom_details)]);document.getElementById('detailsBtn').textContent='Details ('+ds.size+')';
851
+ setMode();render();fitToWindow();
852
+ const ps=document.getElementById('planSel');if(ps)ps.value=String(i);}
853
+ const planSel=document.getElementById('planSel');
854
+ planSel.innerHTML=C.plans.map((p,i)=>`<option value="${i}">${esc(p.sheet)} · ${esc(p.title)}</option>`).join('');
855
+ planSel.onchange=e=>setPlan(+e.target.value);
856
+ document.getElementById('revertB').onclick=()=>{if(confirm('Discard ALL saved edits and reload the originally detected contract? This cannot be undone.')){localStorage.removeItem(LSKEY);location.reload();}};
857
+ const _restored=restoreSaved(); // bring back this browser's saved edits before first paint
858
+ setPlan(C.active==null?0:C.active);
859
+ setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
860
+ // --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
861
+ // The server records a tweak-contract request; the terminal AI picks it up async and PUTs
862
+ // back a revised contract. This is NOT a chat — the status message says so honestly.
863
+ let askAiSnapshots = []; // [{name, dataUrl}] — full dataURL (with prefix) for display; POST sends them as-is
864
+ let askAiEncoding = 0; // in-flight readImageCompressed calls; Send stays disabled until this reaches 0
865
+ function askAiOpen() {
866
+ document.getElementById('askAiModal').style.display = 'flex';
867
+ document.getElementById('askAiText').focus();
868
+ }
869
+ function askAiClose() {
870
+ document.getElementById('askAiModal').style.display = 'none';
871
+ }
872
+ function askAiIsOpen() { return document.getElementById('askAiModal').style.display === 'flex'; }
873
+ function askAiSetStatus(msg, color) {
874
+ const el = document.getElementById('askAiStatus');
875
+ el.style.color = color || 'var(--mut)';
876
+ el.textContent = msg;
877
+ }
878
+ function askAiClearForm() {
879
+ document.getElementById('askAiText').value = '';
880
+ document.getElementById('askAiFile').value = '';
881
+ askAiSnapshots = [];
882
+ askAiRenderThumbs();
883
+ // intentionally does NOT clear the status line — callers set it after the clear
884
+ // so the user can see success/error messages that follow a form reset
885
+ askAiSyncSend();
886
+ }
887
+ function askAiSyncSend() {
888
+ const hasText = document.getElementById('askAiText').value.trim().length > 0;
889
+ document.getElementById('askAiSend').disabled = !hasText || askAiEncoding > 0;
890
+ }
891
+ function askAiRenderThumbs() {
892
+ const wrap = document.getElementById('askAiThumbs');
893
+ wrap.innerHTML = '';
894
+ const drop = document.getElementById('askAiDrop');
895
+ const txt = document.getElementById('askAiDropTxt');
896
+ if (askAiSnapshots.length === 0) {
897
+ drop.classList.remove('has');
898
+ txt.textContent = 'Paste or drop a screenshot (Ctrl+V) — optional';
899
+ } else {
900
+ drop.classList.add('has');
901
+ txt.textContent = 'Drop or paste another screenshot to add';
902
+ }
903
+ askAiSnapshots.forEach((snap, idx) => {
904
+ const wrap2 = document.createElement('div');
905
+ wrap2.className = 'aithumb';
906
+ const img = document.createElement('img');
907
+ img.src = snap.dataUrl; // full dataURL — safe, not user text, no textContent needed
908
+ img.alt = '';
909
+ const rmBtn = document.createElement('button');
910
+ rmBtn.title = 'Remove';
911
+ rmBtn.textContent = '✕';
912
+ rmBtn.onclick = () => { askAiSnapshots.splice(idx, 1); askAiRenderThumbs(); };
913
+ const nameEl = document.createElement('div');
914
+ nameEl.style.cssText = 'font-size:10px;color:var(--mut);text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
915
+ nameEl.textContent = snap.name; // textContent — XSS-safe
916
+ const col = document.createElement('div');
917
+ col.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:2px';
918
+ wrap2.appendChild(img);
919
+ wrap2.appendChild(rmBtn);
920
+ col.appendChild(wrap2);
921
+ col.appendChild(nameEl);
922
+ wrap.appendChild(col);
923
+ });
924
+ }
925
+ function askAiAddFile(file) {
926
+ if (!file || !file.type.startsWith('image/')) return;
927
+ const name = file.name || ('screenshot-' + (askAiSnapshots.length + 1) + '.jpg');
928
+ // reuse readImageCompressed — returns b64 without prefix; we reconstruct the full dataURL for display
929
+ askAiEncoding++;
930
+ askAiSyncSend();
931
+ askAiSetStatus('Encoding image…');
932
+ readImageCompressed(file, b64 => {
933
+ try {
934
+ if (!b64) { askAiSetStatus('Could not read that image — try another.', '#fca5a5'); return; }
935
+ askAiSnapshots.push({ name, dataUrl: 'data:image/jpeg;base64,' + b64 });
936
+ askAiRenderThumbs();
937
+ askAiSetStatus('');
938
+ } finally {
939
+ askAiEncoding--;
940
+ askAiSyncSend();
941
+ }
942
+ });
943
+ }
944
+ document.getElementById('askAiBtn').onclick = askAiOpen;
945
+ document.getElementById('askAiClose').onclick = askAiClose;
946
+ document.getElementById('askAiCancel').onclick = askAiClose;
947
+ document.getElementById('askAiBackdrop').onclick = askAiClose;
948
+ document.getElementById('askAiText').oninput = askAiSyncSend;
949
+ // Drop zone click → file picker
950
+ document.getElementById('askAiDrop').onclick = () => document.getElementById('askAiFile').click();
951
+ // File input (supports multiple)
952
+ document.getElementById('askAiFile').onchange = e => {
953
+ [...(e.target.files || [])].forEach(askAiAddFile);
954
+ e.target.value = '';
955
+ };
956
+ // Drag-and-drop onto the drop zone
957
+ document.getElementById('askAiDrop').addEventListener('dragover', e => { e.preventDefault(); });
958
+ document.getElementById('askAiDrop').addEventListener('drop', e => {
959
+ e.preventDefault();
960
+ [...(e.dataTransfer.files || [])].forEach(askAiAddFile);
961
+ });
962
+ // Paste: only when the Ask-AI panel is open AND the pasted item is an image
963
+ // (text pastes into the textarea still work — we only preventDefault when we find an image item)
964
+ document.addEventListener('paste', e => {
965
+ if (!askAiIsOpen()) return;
966
+ // If the paste target is the textarea, only intercept image items (text paste must still work)
967
+ const items = (e.clipboardData || {}).items || [];
968
+ for (const it of items) {
969
+ if (it.type && it.type.indexOf('image') === 0) {
970
+ const f = it.getAsFile();
971
+ if (f) { e.preventDefault(); askAiAddFile(f); return; }
972
+ }
973
+ }
974
+ });
975
+ // Send handler
976
+ document.getElementById('askAiSend').onclick = async () => {
977
+ const instruction = document.getElementById('askAiText').value.trim();
978
+ if (!instruction) { askAiSetStatus('Please enter an instruction.', '#fca5a5'); return; }
979
+ const sendBtn = document.getElementById('askAiSend');
980
+ sendBtn.disabled = true;
981
+ askAiSetStatus('Saving contract…', 'var(--mut)');
982
+ try {
983
+ await window.flushContract();
984
+ } catch (err) {
985
+ askAiSetStatus('Could not save the contract — check the server and try again.', '#fca5a5');
986
+ sendBtn.disabled = false;
987
+ return;
988
+ }
989
+ askAiSetStatus('Sending…', 'var(--mut)');
990
+ try {
991
+ const res = await fetch('/api/contract-request', {
992
+ method: 'POST',
993
+ headers: { 'content-type': 'application/json' },
994
+ body: JSON.stringify({
995
+ appId: APP_ID,
996
+ instruction,
997
+ snapshots: askAiSnapshots.map(s => ({ name: s.name, dataUrl: s.dataUrl })),
998
+ }),
999
+ });
1000
+ if (res.ok) {
1001
+ askAiClearForm();
1002
+ askAiSetStatus('Queued — the request is waiting for your terminal AI session to pick it up.', '#86efac');
1003
+ // leave the panel open so the user can read the confirmation, then close manually
1004
+ } else {
1005
+ let errText = 'Request failed (' + res.status + ')';
1006
+ try { const body = await res.json(); if (body && body.error) errText = body.error; } catch (_) {}
1007
+ askAiSetStatus(errText, '#fca5a5');
1008
+ sendBtn.disabled = false;
1009
+ }
1010
+ } catch (err) {
1011
+ askAiSetStatus('Network error — check the server and try again.', '#fca5a5');
1012
+ sendBtn.disabled = false;
1013
+ }
1014
+ };
1015
+ // Esc closes the Ask-AI panel (inserted before other modal Esc checks in the existing keydown handler)
1016
+ document.getElementById('reloadB').onclick = () => location.reload();
1017
+ } // end main()
1018
+ boot().catch(err => { document.body.replaceChildren(Object.assign(document.createElement('p'), { textContent: 'Failed to load: ' + err.message, style: 'padding:24px;color:#ef4444' })); });
1019
+ </script></body></html>