@floless/app 0.70.0 → 0.72.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.
package/dist/web/app.css CHANGED
@@ -3308,3 +3308,85 @@ body {
3308
3308
  header { flex-wrap: wrap; gap: 8px; padding: 8px 14px; }
3309
3309
  select, .wf-combo { min-width: 0; }
3310
3310
  }
3311
+
3312
+ /* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
3313
+ /* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
3314
+ UA display:none — these guards keep hidden winning so nothing leaks across modes. */
3315
+ .ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden] { display:none !important; }
3316
+ .mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
3317
+ overflow:hidden; background:var(--surface-2); flex:none; }
3318
+ .mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
3319
+ font-size:12px; padding:7px 14px; letter-spacing:.02em; cursor:pointer; transition:all .15s; }
3320
+ .mode-switch button + button { border-left:1px solid var(--border-strong); }
3321
+ .mode-switch button:hover { color:var(--text); }
3322
+ .mode-switch button.active { background:var(--accent-soft); color:var(--accent-bright); }
3323
+
3324
+ /* Mode gating: Workflows chrome hides wholesale in Workspaces (hidden, never disabled).
3325
+ ≡ keeps only app-wide items; Dashboard is Workflows-scoped (spec §2). */
3326
+ .app.mode-workspaces .controls > label,
3327
+ .app.mode-workspaces .wf-combo, .app.mode-workspaces #wf-update, .app.mode-workspaces #wf-forked,
3328
+ .app.mode-workspaces #guide-beacon, .app.mode-workspaces #run-state,
3329
+ .app.mode-workspaces #restore-btn, .app.mode-workspaces #customize-btn,
3330
+ .app.mode-workspaces #compile-btn, .app.mode-workspaces #sim-btn,
3331
+ .app.mode-workspaces #run-btn, .app.mode-workspaces #stop-run-btn,
3332
+ .app.mode-workspaces #ext-badge,
3333
+ .app.mode-workspaces #menu .menu-item[data-action="view-switch"],
3334
+ .app.mode-workspaces #menu .menu-item[data-action="find"],
3335
+ .app.mode-workspaces #menu .menu-item[data-action="open"],
3336
+ .app.mode-workspaces #menu .menu-item[data-action="save"],
3337
+ .app.mode-workspaces #menu .menu-item[data-action="new-workflow"],
3338
+ .app.mode-workspaces #menu .menu-item[data-action="import"],
3339
+ .app.mode-workspaces #menu .menu-item[data-action="agents"],
3340
+ .app.mode-workspaces #menu .menu-item[data-action="graft"],
3341
+ .app.mode-workspaces #menu .menu-item[data-action="bake"] { display:none; }
3342
+ .app.mode-workspaces .topology, .app.mode-workspaces .canvas-toolbar,
3343
+ .app.mode-workspaces .hint, .app.mode-workspaces .fav-bar, .app.mode-workspaces .dashboard,
3344
+ .app.mode-workspaces .notes-strip, .app.mode-workspaces .find-overlay,
3345
+ /* the ws surfaces carry their own headings — hide the canvas's "Canvas · transparency layer" label */
3346
+ .app.mode-workspaces .canvas > .panel-label { display:none !important; }
3347
+ /* the editor owns center+right in a project — collapse the inspect column entirely */
3348
+ .app.mode-workspaces { --right-width:0px !important; }
3349
+ .app.mode-workspaces .inspect { display:none; }
3350
+ .app.mode-workspaces .canvas { cursor:default; touch-action:auto; }
3351
+ .ws-spine { display:flex; align-items:center; gap:10px; }
3352
+ #ws-proj-name { max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3353
+ #ws-proj-trigger { display:inline-flex; align-items:center; gap:8px; }
3354
+
3355
+ /* landing */
3356
+ .ws-landing { flex:1; min-height:0; overflow-y:auto; padding:6px 20px 24px; }
3357
+ .ws-head h2 { font-size:17px; font-weight:650; letter-spacing:.01em; }
3358
+ .ws-head .ws-sub { color:var(--text-muted); font-size:12px; margin:2px 0 14px; }
3359
+ .ws-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); align-content:start; }
3360
+ .ws-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; overflow:hidden;
3361
+ cursor:pointer; transition:all .15s; text-align:left; padding:0; display:flex; flex-direction:column; }
3362
+ .ws-card:hover { border-color:var(--accent-dim); transform:translateY(-1px); }
3363
+ .ws-card .ws-thumb { height:88px; background:var(--surface-2); border-bottom:1px solid var(--border);
3364
+ display:flex; align-items:center; justify-content:center; position:relative; color:var(--accent); font-size:24px; }
3365
+ .ws-card .ws-kind { position:absolute; top:8px; left:8px; font-size:9px; text-transform:uppercase; letter-spacing:.1em;
3366
+ color:var(--accent-bright); background:var(--surface); border:1px solid var(--accent-dim); padding:2px 7px; border-radius:4px; }
3367
+ .ws-card .ws-body { padding:11px 13px 13px; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
3368
+ .ws-card .ws-name { font-size:13px; font-weight:600; color:var(--text); }
3369
+ .ws-card .ws-meta { font-size:11px; color:var(--text-muted); margin-top:4px; }
3370
+ .ws-card .ws-meta .t { font-family:var(--mono); font-size:10px; color:var(--text-dim); }
3371
+ .ws-card .ws-kebab { background:transparent; border:1px solid transparent; color:var(--text-dim);
3372
+ padding:2px 7px; border-radius:4px; font-size:14px; line-height:1; flex:none; }
3373
+ .ws-card .ws-kebab:hover { border-color:var(--border-strong); color:var(--text); }
3374
+ .ws-seed { border:2px dashed var(--accent-dim); background:transparent; border-radius:8px; min-height:150px;
3375
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; cursor:pointer;
3376
+ color:var(--text-muted); font-size:12px; width:100%; text-align:center; padding:12px; }
3377
+ .ws-seed:hover { background:var(--accent-soft); border-color:var(--accent); }
3378
+ .ws-seed .plus { font-size:24px; color:var(--accent); line-height:1; }
3379
+ .ws-empty-note { color:var(--text-dim); font-size:12px; margin-top:10px; }
3380
+
3381
+ /* open project */
3382
+ .ws-project { flex:1; min-height:0; display:flex; flex-direction:column; }
3383
+ .ws-crumb-row { padding:12px 14px 8px; display:flex; justify-content:space-between; align-items:center; }
3384
+ .ws-crumb { font-size:12px; color:var(--text-muted); }
3385
+ .ws-crumb-link { background:none; border:none; padding:0; color:var(--text-muted); font-size:12px; cursor:pointer; }
3386
+ .ws-crumb-link:hover { color:var(--accent); }
3387
+ .ws-crumb-sep { color:var(--text-dim); margin:0 6px; }
3388
+ .ws-crumb-cur { color:var(--text); }
3389
+ .ws-status { font-size:10px; color:var(--text-dim); font-style:italic; }
3390
+ .tabs.ws-step-tabs { background:transparent; padding:0 6px; }
3391
+ .tabs.ws-step-tabs button { flex:0 0 auto; padding:11px 16px; }
3392
+ .ws-frame { flex:1; min-height:0; width:100%; border:none; background:var(--bg); }
package/dist/web/aware.js CHANGED
@@ -6445,6 +6445,7 @@
6445
6445
  copyToClipboard,
6446
6446
  instructionFor,
6447
6447
  markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
6448
+ formModal, // the shared styled prompt/form modal — workspaces.js reuses it (never a native prompt)
6448
6449
  };
6449
6450
 
6450
6451
  // ── boot ──────────────────────────────────────────────────────────────────────
@@ -33,6 +33,13 @@
33
33
  </span>
34
34
  <span class="name">FloLess</span>
35
35
  </div>
36
+ <!-- Top-level mode switch (Workflows = node canvas + Run; Workspaces = project/document
37
+ apps, no Run). Segmented control, Ctrl+1/Ctrl+2. See docs/superpowers/specs/
38
+ 2026-07-03-workspaces-two-mode-shell-design.md. -->
39
+ <div class="mode-switch" id="mode-switch" role="tablist" aria-label="Mode">
40
+ <button type="button" data-mode="workflows" class="active" role="tab" aria-selected="true">Workflows</button>
41
+ <button type="button" data-mode="workspaces" role="tab" aria-selected="false">Workspaces</button>
42
+ </div>
36
43
  <!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the user's
37
44
  custom panels from ~/.floless/ui/extensions.json) moved into the ≡ menu in #149.
38
45
  The "dashboard updated" signal now rides the ≡ hamburger badge (.has-dash-update)
@@ -61,6 +68,20 @@
61
68
  <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
62
69
  (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
63
70
  <span class="ctl-sep" aria-hidden="true"></span>
71
+ <!-- Workspaces project spine — swapped in wholesale by mode (the restore/customize
72
+ swap precedent). Approve takes Run's filled-primary slot; Export/Rollback arrive
73
+ in slices 2/3. Hidden (never disabled) outside an open project. -->
74
+ <div class="ws-spine" id="ws-spine" hidden>
75
+ <label id="ws-proj-label">project</label>
76
+ <button id="ws-proj-trigger" class="wf-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
77
+ data-tip="This project — open another, rename, duplicate, archive">
78
+ <span id="ws-proj-name">—</span>
79
+ <svg class="wf-caret" width="10" height="6" viewBox="0 0 10 6" aria-hidden="true"><path fill="currentColor" d="M0 0l5 6 5-6z"/></svg>
80
+ </button>
81
+ <span class="ctl-sep" aria-hidden="true"></span>
82
+ <button id="ws-approve" type="button" class="primary"
83
+ data-tip="Locks in this version of the model — your edits already auto-save; this is the sign-off.">✓ Approve</button>
84
+ </div>
64
85
  <!-- Per-workflow onboarding beacon. Pulses "✨ Start here" while this workflow is
65
86
  new to the user (no first Run completed, not dismissed); calms to a quiet
66
87
  "❔ Guide" after — always clickable for re-reference. Hidden until an app loads. -->
@@ -134,6 +155,24 @@
134
155
  ~/.floless/ui/extensions.json here; the canvas children hide via
135
156
  .canvas.view-dashboard). Composed by the terminal AI, rendered by us. -->
136
157
  <div class="dashboard" id="dashboard" hidden></div>
158
+ <!-- Workspaces mode surfaces (web/workspaces.js renders both; hidden outside the mode).
159
+ Landing = project grid; project = step tabs over the editor/filter iframes. -->
160
+ <div class="ws-landing" id="ws-landing" hidden></div>
161
+ <div class="ws-project" id="ws-project" hidden>
162
+ <div class="ws-crumb-row">
163
+ <span class="ws-crumb"><button type="button" id="ws-back" class="ws-crumb-link">Workspaces</button>
164
+ <span class="ws-crumb-sep">›</span><span id="ws-crumb-name" class="ws-crumb-cur">—</span></span>
165
+ <span class="ws-status" id="ws-status"></span>
166
+ </div>
167
+ <div class="tabs ws-step-tabs" id="ws-step-tabs" role="tablist" aria-label="Project steps">
168
+ <button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
169
+ <button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
170
+ </div>
171
+ <!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
172
+ Same-origin like #contract-editor-frame (they call /api/contract directly). -->
173
+ <iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
174
+ <iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
175
+ </div>
137
176
  <div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
138
177
  <div class="fav-bar" id="fav-bar">
139
178
  <div class="fav-bar-label"><span class="star">★</span><span>Templates</span></div>
@@ -767,9 +806,14 @@
767
806
  </div>
768
807
  </div>
769
808
  </div>
809
+ <!-- Project picker + lifecycle. The FULL lifecycle set lives HERE and only here — ≡ carries
810
+ nothing project-specific (spec §2). Populated per-open in workspaces.js. -->
811
+ <div class="menu ws-proj-menu" id="ws-proj-menu" role="menu" hidden></div>
812
+
770
813
  <script src="app.js"></script>
771
814
  <script src="renderers.js"></script>
772
815
  <script src="aware.js"></script>
773
816
  <script src="panels.js"></script>
817
+ <script src="workspaces.js"></script>
774
818
  </body>
775
819
  </html>
@@ -526,6 +526,7 @@ function applyCopes(mesh, cuts) {
526
526
 
527
527
  function buildFromScene(sc) {
528
528
  clearRoot();
529
+ resetConnState(); // a fresh scene rebuilds selection from scratch — drop any stale connection envelope/context
529
530
  for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
530
531
  groupColor.clear(); baseMat.clear();
531
532
  sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
@@ -547,6 +548,7 @@ function buildFromScene(sc) {
547
548
  if (memberCuts && memberCuts.length) applyCopes(mesh, memberCuts); // notch a coped member end
548
549
  mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
549
550
  mesh.userData.derived = !!(el.kind && el.kind !== 'box'); // connection parts: rendered, not member-editable
551
+ mesh.userData.conn = el.conn || null; mesh.userData.connKind = el.connKind || null; // Connection Component membership (Slice A) — the whole-select/drill handle
550
552
  root.add(mesh); meshById.set(el.id, mesh);
551
553
  box.expandByObject(mesh);
552
554
  }
@@ -1302,6 +1304,7 @@ function onKey(e) {
1302
1304
  if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1303
1305
  if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
1304
1306
  if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
1307
+ if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
1305
1308
  if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
1306
1309
  const k = e.key.toLowerCase();
1307
1310
  // Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
@@ -1354,6 +1357,16 @@ function onDblClick(e) {
1354
1357
  const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false); // incl. connection parts
1355
1358
  if (!hits.length) return; // empty space → no-op (Fit / Home fit-all; avoids an accidental camera teleport)
1356
1359
  const p = hits[0].point, mesh = hits[0].object;
1360
+ // Connection drill-down (Slice A): double-clicking a part of a connection we're NOT already inside ENTERS
1361
+ // that connection (selects the part under the cursor) and frames it. A part of the connection we're
1362
+ // already in, or a bare member, falls through to the classic zoom-to-part below (non-breaking).
1363
+ const dblConn = mesh.userData && mesh.userData.conn;
1364
+ if (dblConn && ctxConn !== dblConn) {
1365
+ enterConn(dblConn, mesh.userData.id);
1366
+ const cb = connBox(dblConn);
1367
+ if (!cb.isEmpty()) { const vDir = camera.position.clone().sub(controls.target).normalize(); fitCamera(cb, vDir.lengthSq() > 0.5 ? vDir : undefined); }
1368
+ return;
1369
+ }
1357
1370
  if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
1358
1371
  const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
1359
1372
  const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
@@ -1378,6 +1391,7 @@ function setSelection(ids) {
1378
1391
  }
1379
1392
  applyDisplayMode(); // selection swapped the materials → re-apply wire/xray
1380
1393
  selIds = new Set(set);
1394
+ reconcileConnState(set); // any selection path (2D click, box-select, keyboard) must not leave a stale connection envelope/drill
1381
1395
  rebuildEndpoints(); // endpoint dots follow the selection (+ any hover)
1382
1396
  updateStatusChip();
1383
1397
  }
@@ -1817,14 +1831,19 @@ const CYCLE_TOL_PX = 8;
1817
1831
  function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
1818
1832
  function clickSelect(cx, cy, ctrl) {
1819
1833
  let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
1820
- if (!hits.length) { resetCycle(); if (api && api.onSelect) api.onSelect(null, !!ctrl); return; }
1821
- if (ctrl) { resetCycle(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest
1834
+ if (!hits.length) { resetCycle(); clearConnSel(); return; } // empty → deselect (clears any connection too)
1835
+ if (ctrl) { resetCycle(); resetConnState(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest RAW part (leaves connection mode)
1822
1836
  const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
1823
1837
  && cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
1824
1838
  if (same) cycleIdx = (cycleIdx + 1) % hits.length; else { cycleIds = hits; cycleIdx = 0; cycleAnchor = [cx, cy]; }
1825
- const pick = hits[cycleIdx], grp = boltGroupOf(pick); // a bolt → select the whole bolt ARRAY (a pattern, not one bolt)
1826
- if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
1827
- else if (api && api.onSelect) api.onSelect(pick, false);
1839
+ const pick = hits[cycleIdx], conn = connOf(pick);
1840
+ if (!conn) { resetConnState(); if (api && api.onSelect) api.onSelect(pick, false); return; } // a bare member → normal single select
1841
+ if (ctxConn === conn) { // drilled INTO this connection → clicks land on its parts (bolt array or a single part)
1842
+ const grp = boltGroupOf(pick);
1843
+ if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); } else if (api && api.onSelect) api.onSelect(pick, false);
1844
+ return;
1845
+ }
1846
+ selectWholeConn(conn); // at root (or over a different connection) → select the WHOLE connection
1828
1847
  }
1829
1848
  // A bolt/head/nut id → all bolt-group part ids of the same joint (the connection's bolt array); else just [id].
1830
1849
  function boltGroupOf(id) {
@@ -1834,6 +1853,63 @@ function boltGroupOf(id) {
1834
1853
  const ids = [...meshById.keys()].filter((k) => { const c = k.indexOf(':'); return c >= 0 && k.slice(0, c) === jid && /^(bolt|head|nut)/.test(k.slice(c + 1)); });
1835
1854
  return ids.length ? ids : [id];
1836
1855
  }
1856
+
1857
+ // ── Connection Components (Slice A): select/drill a whole connection (base-plate / shear-plate) as ONE
1858
+ // unit. `selConn` = the connection currently whole-selected at root; `ctxConn` = the connection we've
1859
+ // DRILLED INTO (double-click) so subsequent clicks land on its individual parts. Both derive from the
1860
+ // `conn` tag every ConnPart carries (buildFromScene stashes el.conn on userData). A bare member (no conn)
1861
+ // clears both. The host editor re-derives its breadcrumb + component inspector from the selection ids each
1862
+ // render() — no view→editor callback needed; reconcileConnState() (from setSelection) keeps this honest.
1863
+ let selConn = null, ctxConn = null;
1864
+ function connOf(id) { const m = id && meshById.get(id); return m && m.userData ? (m.userData.conn || null) : null; }
1865
+ function connChildIds(conn) { const out = []; for (const [id, m] of meshById) { if (m.userData && m.userData.conn === conn) out.push(id); } return out; } // every rendered part of this connection
1866
+ function connBox(conn) { const b = new THREE.Box3(); for (const m of meshById.values()) { if (m.userData && m.userData.conn === conn && m.visible) b.expandByObject(m); } return b; }
1867
+ // The dashed brand-blue envelope = the single "this is a group" cue for a whole-connection selection.
1868
+ let connEnvelope = null;
1869
+ function clearConnEnvelope() { if (connEnvelope) { if (overlayScene) overlayScene.remove(connEnvelope); connEnvelope.geometry.dispose(); connEnvelope.material.dispose(); connEnvelope = null; } }
1870
+ function renderConnEnvelope(conn) {
1871
+ clearConnEnvelope();
1872
+ if (!conn || !overlayScene) return;
1873
+ const b = connBox(conn); if (b.isEmpty()) return;
1874
+ b.expandByScalar(Math.max(6, b.getSize(new THREE.Vector3()).length() * 0.02)); // a little breathing room around the parts
1875
+ connEnvelope = new THREE.Box3Helper(b, new THREE.Color(SELECT_EMISSIVE)); // --brand
1876
+ connEnvelope.material.depthTest = false; connEnvelope.material.transparent = true; connEnvelope.material.opacity = 0.6; connEnvelope.renderOrder = 996;
1877
+ overlayScene.add(connEnvelope);
1878
+ }
1879
+ function resetConnState() { selConn = null; ctxConn = null; clearConnEnvelope(); } // internal reset, no callbacks
1880
+ // Select the WHOLE connection at root (single-click a part, or a breadcrumb click). Clears any drill context.
1881
+ function selectWholeConn(conn) {
1882
+ if (!conn || !connChildIds(conn).length) return clearConnSel();
1883
+ selConn = conn; ctxConn = null;
1884
+ renderConnEnvelope(conn);
1885
+ if (api && api.onSelectMany) api.onSelectMany(connChildIds(conn));
1886
+ }
1887
+ // Clear any connection selection/drill (back to bare Model root — deselects).
1888
+ function clearConnSel() { resetConnState(); if (api && api.onSelect) api.onSelect(null, false); }
1889
+ // Enter a connection (double-click) and select the part under the cursor — the drill-in step.
1890
+ function enterConn(conn, partId) {
1891
+ ctxConn = conn; selConn = conn; clearConnEnvelope(); // inside → the part-level highlight carries; no whole envelope
1892
+ const grp = boltGroupOf(partId);
1893
+ if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
1894
+ else if (api && api.onSelect) api.onSelect(partId, false);
1895
+ }
1896
+ // Ascend one level: drilled part → whole connection → nothing. Returns true if it consumed the gesture.
1897
+ function ascendConn() {
1898
+ if (ctxConn) { selectWholeConn(ctxConn); return true; } // part → whole
1899
+ if (selConn) { clearConnSel(); return true; } // whole → nothing
1900
+ return false;
1901
+ }
1902
+ // Keep the connection state honest against ANY selection change — not just the 3D click paths but a 2D
1903
+ // member click, box-select, keyboard, or Delete that route through setSelection(). Whole: the full child
1904
+ // set must still be selected, else drop the stale envelope; drilled: the selection must stay WITHIN the
1905
+ // connection, else exit the drill. Callback-free (resetConnState) so it can't recurse through render().
1906
+ function reconcileConnState(set) {
1907
+ if (!selConn) return;
1908
+ const kids = connChildIds(selConn);
1909
+ if (ctxConn) { if (!set.size || ![...set].every((id) => kids.includes(id))) resetConnState(); } // drilled: any pick outside the connection → exit
1910
+ else if (!kids.length || !kids.every((k) => set.has(k))) resetConnState(); // whole: must remain the full set, else drop the envelope
1911
+ }
1912
+ function connContext() { return { selConn, ctxConn }; } // test/editor read
1837
1913
  // The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
1838
1914
  // null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
1839
1915
  // the member body, letting you grab one end to stretch it.
@@ -2575,7 +2651,7 @@ function dispose() {
2575
2651
  gridTexCache.clear();
2576
2652
  clearRoot();
2577
2653
  if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
2578
- clearClipGizmo(); setClipPreview(null); overlayScene = null;
2654
+ clearConnEnvelope(); clearClipGizmo(); setClipPreview(null); overlayScene = null;
2579
2655
  clips = []; workArea = null; clipMode = null; selectedClipIds.clear(); clipBoxDraft = null; // clips live on the renderer; drop them with the renderer
2580
2656
  if (renderer) renderer.dispose();
2581
2657
  renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
@@ -2637,6 +2713,7 @@ window.Steel3DView = {
2637
2713
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2638
2714
  setRefLine, refLine: () => refLineOn,
2639
2715
  setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2716
+ selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
2640
2717
  setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2641
2718
  syncMemberLabels, // editor calls after a mark/id edit to refresh labels
2642
2719
  setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
@@ -33,6 +33,13 @@
33
33
  .detf input{width:100%}
34
34
  #detOpacity{accent-color:var(--brand);flex:1;min-width:0}
35
35
  #zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
36
+ /* Connection Component breadcrumb (Slice A) — a floating chip over the 3D canvas, same recipe as #zoombar. */
37
+ #connCrumb{position:absolute;left:50%;top:48px;transform:translateX(-50%);display:none;align-items:center;gap:1px;max-width:min(72%,560px);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:58;font-size:12px;white-space:nowrap;overflow:hidden} /* below #m3dBar (top:12,h~29,z:59); z:58 keeps it clickable above the dim-label chips (57) */
38
+ #connCrumb .seg{color:var(--mut);cursor:pointer;padding:1px 4px;border-radius:4px;background:none;border:0;font:inherit;max-width:260px;overflow:hidden;text-overflow:ellipsis}
39
+ #connCrumb .seg:hover{color:var(--text);text-decoration:underline}
40
+ #connCrumb .seg.cur{color:var(--brand);font-weight:600;cursor:default;text-decoration:none}
41
+ #connCrumb .sep{color:var(--mut);opacity:.7;padding:0 2px}
42
+ .pilllink{background:none;border:0;color:var(--brand);cursor:pointer;font:inherit;padding:0;text-decoration:underline}
36
43
  aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
37
44
  aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
38
45
  select,input{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:6px;width:100%;font:13px system-ui}
@@ -496,6 +503,7 @@
496
503
  <div id=stagewrap>
497
504
  <div id=stage><svg id=svg></svg></div>
498
505
  <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
506
+ <div id=connCrumb role=navigation aria-label="Connection breadcrumb"></div>
499
507
  <div id=m3dBar role=group aria-label="3D view controls">
500
508
  <!-- Camera projection — dropdown (like Plane / Work area); the button shows the current mode -->
501
509
  <div class=m3dwrap>
@@ -639,6 +647,11 @@
639
647
  <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>
640
648
  <script>
641
649
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
650
+ // Workspaces: a project-keyed contract lives at projects/<id>/contract.json. Empty PROJECT
651
+ // = legacy app-keyed behaviour, byte-for-byte today's URLs. PROJECT_QS is appended to every
652
+ // /api/contract call; the contract-request (Ask AI) bodies carry `project` for slice-4 context.
653
+ const PROJECT = new URLSearchParams(location.search).get('project') || '';
654
+ const PROJECT_QS = PROJECT ? ('?project=' + encodeURIComponent(PROJECT)) : '';
642
655
  let C, PAL, WT;
643
656
  let TARGET_CONF = null; // the app's target_confidence input default (%), or null when undeclared — drives the chip's "· target N%" goal
644
657
  let view3d = false, view3dReady = false; // 2D|3D toggle state; view3dReady once Steel3DView.init has run
@@ -659,7 +672,7 @@ function showEmpty(icon, headline, bodyText, appIdText) {
659
672
  }
660
673
  async function boot() {
661
674
  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; }
662
- const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID));
675
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS);
663
676
  if (!res.ok) {
664
677
  if (res.status === 404) {
665
678
  showEmpty('⊞', 'No takeoff for', 'Read a structural drawing first — ask your terminal AI to run the workflow, then open this editor again.', APP_ID);
@@ -920,7 +933,7 @@ function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),
920
933
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
921
934
  async function persistServer(){try{
922
935
  lastLocalPut = Date.now();
923
- const res=await fetch('/api/contract/'+encodeURIComponent(APP_ID),{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(C)});
936
+ const res=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+PROJECT_QS,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(C)});
924
937
  setSaved(res.ok?'ok':'err');
925
938
  if(!res.ok)console.error('server save rejected ('+res.status+')',await res.text().catch(()=>'')); // 400 = schema reject: edits won't be what Approve bakes
926
939
  }catch(e){setSaved('err');console.error('server save failed',e);}}
@@ -932,7 +945,7 @@ function scheduleSave(){setSaved('dirty');clearTimeout(saveT);saveT=setTimeout((
932
945
  window.flushContract = async () => {
933
946
  clearTimeout(saveT);
934
947
  lastLocalPut = Date.now();
935
- const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), {
948
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS, {
936
949
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
937
950
  });
938
951
  setSaved(res.ok ? 'ok' : 'err');
@@ -1377,6 +1390,7 @@ function render(){
1377
1390
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
1378
1391
  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(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
1379
1392
  if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
1393
+ try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1380
1394
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
1381
1395
  }
1382
1396
  function updDup(){const n=redundantDups().length;
@@ -1417,6 +1431,54 @@ function stats(){
1417
1431
  // "Varies" placeholders + the indeterminate "default" checkbox in the multi-edit panel. get() must return a primitive.
1418
1432
  const VARIES=Symbol('varies');
1419
1433
  function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
1434
+ // ── Connection Components (Slice A). Derive the current connection-selection state from selIds + the
1435
+ // resolved scene parts (partsById carries each part's `conn` tag). Returns {conn,kind,main,joint,childIds,
1436
+ // whole,mode} or null when the selection isn't one connection's parts. `whole` = every selectable part of
1437
+ // the connection is selected (copes are subtractive → not rendered/selectable, so excluded). Robust: no
1438
+ // dependence on cross-view callback timing — every render() re-derives it.
1439
+ function connSelInfo(){
1440
+ const ids=[...selIds]; if(!ids.length) return null;
1441
+ let conn=null;
1442
+ for(const id of ids){ const el=(partsById||{})[id]; const c=el&&el.conn; if(!c) return null; if(conn==null) conn=c; else if(conn!==c) return null; }
1443
+ if(!conn) return null;
1444
+ const j=(C.joints||[]).find(x=>x&&x.id===conn); if(!j) return null;
1445
+ const childIds=Object.keys(partsById||{}).filter(id=>{const el=partsById[id];return el&&el.conn===conn&&el.kind!=='cut';});
1446
+ const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
1447
+ return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
1448
+ }
1449
+ // The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
1450
+ // 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
1451
+ function updateConnCrumb(){
1452
+ const el=document.getElementById('connCrumb'); if(!el) return;
1453
+ const cs=view3d?connSelInfo():null;
1454
+ if(!cs){ el.style.display='none'; el.innerHTML=''; return; }
1455
+ const name=(cs.kind==='base-plate'?'Base plate':cs.kind==='shear-plate'?'Shear plate':'Connection')+' · '+cs.main;
1456
+ let html='<button class=seg data-lvl=root data-tip="Back to the model (deselect)">Model</button><span class=sep>▸</span>';
1457
+ if(cs.whole){ html+='<span class="seg cur">'+esc(name)+'</span>'; }
1458
+ else{
1459
+ html+='<button class=seg data-lvl=whole data-tip="Select the whole connection">'+esc(name)+'</button><span class=sep>▸</span>';
1460
+ const partId=[...selIds].find(id=>/:bolt\d+$/.test(id))||[...selIds][0];
1461
+ const pel=(partsById||{})[partId]; const plbl=(pel&&pel.meta&&pel.meta.label)||'Part';
1462
+ html+='<span class="seg cur">'+esc(plbl)+'</span>';
1463
+ }
1464
+ el.innerHTML=html; el.style.display='flex';
1465
+ {const b=el.querySelector('[data-lvl=root]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel(); else{selIds=new Set();render();} };}
1466
+ {const b=el.querySelector('[data-lvl=whole]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.selectWholeConn)window.Steel3DView.selectWholeConn(cs.conn); };}
1467
+ }
1468
+ // Route a "modify this connection" ask through the Request relay (intent+target). A recipe connection's
1469
+ // geometry is member-derived, so move/replace/adjust go to the terminal AI (the UI relays intent) — unlike
1470
+ // Delete, which is a direct, deterministic contract edit.
1471
+ async function connModifyRequest(j){
1472
+ if(!j) return;
1473
+ try{await window.flushContract();}catch(_){}
1474
+ try{persist();}catch(_){}
1475
+ const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':'connection';
1476
+ const instruction='Modify the '+kindName+' connection "'+j.id+'" on member '+j.main+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
1477
+ try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
1478
+ body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main]}})});
1479
+ toast(res.ok?'Change queued for your terminal AI session':'Could not queue the request');
1480
+ }catch(_){toast('Could not queue the request');}
1481
+ }
1420
1482
  function panel(){
1421
1483
  const p=document.getElementById('panel');
1422
1484
  if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
@@ -1499,6 +1561,38 @@ function panel(){
1499
1561
  {const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
1500
1562
  return;
1501
1563
  }}
1564
+ // A WHOLE connection selected (Slice A) — the Component inspector: type badge + editability chip +
1565
+ // on-member link + part count + a read-only param summary, then Delete (direct contract edit) /
1566
+ // Modify (relay) / Edit-on-member. Precedes the single-part branch below (which handles the DRILLED case).
1567
+ {const cs=connSelInfo();
1568
+ if(cs&&cs.whole){
1569
+ const j=cs.joint,isBP=j.kind==='base-plate',pp=j.params||{};
1570
+ const plate=(partsById||{})[cs.conn+':plate']||null;
1571
+ const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
1572
+ const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
1573
+ const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
1574
+ const sz=plate?dim(plate.width)+' × '+dim(plate.depth):'<span style="color:var(--mut)">auto</span>';
1575
+ let body='';
1576
+ if(isBP)body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.thickness))+sec('Anchors')+kv('Grid (cols × rows)',esc(`${pp.boltCols||2} × ${pp.boltRows||2}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(24));
1577
+ else body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.plateThickness))+sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(20))+sec('Weld')+kv('Leg',pp.weldLeg?dim(pp.weldLeg):dim(6));
1578
+ p.innerHTML=`<span class=badge>${isBP?'Base plate':'Shear plate'}</span>
1579
+ <div class=row style="margin:0 0 6px"><span class=chip style="border-color:var(--brand);color:#bfdbfe">Parametric — editable</span></div>
1580
+ <div class="row hint" style="margin:0 0 2px">On <button class=pilllink id=cmpMember data-tip="Select ${esc(j.main)}">${esc(j.main)}</button> · ${cs.childIds.length} parts</div>
1581
+ <div class="row hint" style="margin:0 0 6px;font-size:11px">Double-click to enter and pick a part · <b>Esc</b> steps back.</div>
1582
+ ${body}
1583
+ <div class=divrow><hr></div>
1584
+ <div class="row f" style="gap:6px;flex-wrap:wrap">
1585
+ <button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
1586
+ <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1587
+ <button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
1588
+ </div>`;
1589
+ const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1590
+ {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
1591
+ {const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
1592
+ {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1593
+ {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1594
+ return;
1595
+ }}
1502
1596
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
1503
1597
  // read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
1504
1598
  {const selList=[...selIds];
@@ -1523,7 +1617,7 @@ function panel(){
1523
1617
  const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${t}</span><hr></div>`;
1524
1618
  let body='';
1525
1619
  if(pk==='plate'&&j.kind==='shear-plate')body=sec('Plate')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness))+kv('Weld leg',v('weldLeg','mm'))+kv('Clearance',v('clearance','mm'));
1526
- else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',`${pp.boltCols||1} × ${pp.boltRows||3}`)+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
1620
+ else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
1527
1621
  else if(pk==='weld')body=sec('Weld')+kv('Leg',v('weldLeg','mm'));
1528
1622
  else if(pk==='cope')body=sec('Cope')+kv('Length',dim(el&&el.width))+kv('Depth',dim(el&&el.depth))+kv('Re-entrant radius',v('copeRadius','mm'));
1529
1623
  else if(pk==='stiff')body=sec('Stiffener')+`<div class=hint style="margin:0">Opposite-side web stiffener on the support.</div>`;
@@ -1538,8 +1632,9 @@ function panel(){
1538
1632
  ${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
1539
1633
  ${body}
1540
1634
  <div class=divrow><hr></div>
1541
- <div class="row f"><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1542
- const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1635
+ <div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=partBack data-tip="Back to the whole connection (Esc)">◂ Connection</button><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1636
+ {const bb=document.getElementById('partBack');if(bb)bb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.ascendConn)window.Steel3DView.ascendConn();};}
1637
+ const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1543
1638
  return;
1544
1639
  }}
1545
1640
  const arr=selArr();
@@ -2763,7 +2858,7 @@ async function detailRequest(intent,place,note){
2763
2858
  : 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
2764
2859
  const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
2765
2860
  try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
2766
- body:JSON.stringify({appId:APP_ID,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
2861
+ body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
2767
2862
  toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
2768
2863
  }catch(_){toast('Could not queue the request');}}
2769
2864
  // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
@@ -3886,6 +3981,7 @@ document.getElementById('askAiSend').onclick = async () => {
3886
3981
  headers: { 'content-type': 'application/json' },
3887
3982
  body: JSON.stringify({
3888
3983
  appId: APP_ID,
3984
+ project: PROJECT || undefined,
3889
3985
  instruction,
3890
3986
  snapshots: askAiSnapshots.map(s => ({ name: s.name, dataUrl: s.dataUrl })),
3891
3987
  }),
@@ -128,6 +128,9 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
128
128
  import { matchesActive, modeDims, canonicalMode, selFromFilter, eyedropperAdd, countShown, bgRect, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
129
129
 
130
130
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
131
+ // Workspaces: project-keyed contract (see steel-editor.html). Empty = legacy app-keyed URLs.
132
+ const PROJECT = new URLSearchParams(location.search).get('project') || '';
133
+ const PROJECT_QS = PROJECT ? ('?project=' + encodeURIComponent(PROJECT)) : '';
131
134
  const SVGNS = 'http://www.w3.org/2000/svg';
132
135
  const PALETTE = ['#3b82f6','#22d3ee','#a78bfa','#f59e0b','#34d399','#f472b6','#60a5fa','#facc15'];
133
136
 
@@ -160,7 +163,7 @@ function markDirty(){ dirty = true; saveBtn.disabled = false; setSaved('dirty');
160
163
  async function boot(){
161
164
  if(!APP_ID){ showEmpty('No app specified.'); return; }
162
165
  let res;
163
- try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID)); }
166
+ try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS); }
164
167
  catch(e){ showEmpty('Could not reach the server.'); return; }
165
168
  if(!res.ok){ showEmpty(); return; }
166
169
  C = await res.json();
@@ -410,7 +413,7 @@ async function save(){
410
413
  C.filter = F;
411
414
  saveBtn.disabled = true; setSaved('');
412
415
  try {
413
- const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
416
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
414
417
  if(!res.ok) throw new Error(await res.text());
415
418
  dirty = false; setSaved('saved');
416
419
  } catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }