@floless/app 0.71.0 → 0.72.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +407 -232
- package/dist/web/app.css +82 -0
- package/dist/web/aware.js +1 -0
- package/dist/web/index.html +44 -0
- package/dist/web/steel-3d-view.js +22 -1
- package/dist/web/steel-editor.html +10 -4
- package/dist/web/steel-filter.html +5 -2
- package/dist/web/workspaces.js +270 -0
- package/package.json +1 -1
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 ──────────────────────────────────────────────────────────────────────
|
package/dist/web/index.html
CHANGED
|
@@ -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>
|
|
@@ -2454,6 +2454,22 @@ function membersInRect(x0, y0, x1, y1) {
|
|
|
2454
2454
|
}
|
|
2455
2455
|
return out;
|
|
2456
2456
|
}
|
|
2457
|
+
// Connection Components whose CENTRE falls inside a marquee rect (mirrors membersInRect's member-centre
|
|
2458
|
+
// test, applied to each connection's bounding box) — so area-select picks up connections, not just members.
|
|
2459
|
+
function connsInRect(x0, y0, x1, y1) {
|
|
2460
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
2461
|
+
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
|
2462
|
+
const conns = new Set();
|
|
2463
|
+
for (const m of meshById.values()) { const c = m.userData && m.userData.conn; if (c && m.visible) conns.add(c); }
|
|
2464
|
+
const out = [];
|
|
2465
|
+
for (const conn of conns) {
|
|
2466
|
+
const b = connBox(conn); if (b.isEmpty()) continue;
|
|
2467
|
+
const w = b.getCenter(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
|
|
2468
|
+
const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
|
|
2469
|
+
if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(conn);
|
|
2470
|
+
}
|
|
2471
|
+
return out;
|
|
2472
|
+
}
|
|
2457
2473
|
|
|
2458
2474
|
function onUp(e) {
|
|
2459
2475
|
if (e.button === 2) rightDownXY = null; // end the click-vs-drag test (rightMoved keeps the verdict for the contextmenu that follows)
|
|
@@ -2461,7 +2477,12 @@ function onUp(e) {
|
|
|
2461
2477
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; boxSel = pending = dragging = null; if (controls) controls.enabled = true; return; } // 3D hidden mid-gesture → drop stale gesture state (no resume on re-show)
|
|
2462
2478
|
const bs = boxSel; boxSel = null;
|
|
2463
2479
|
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
2464
|
-
if (bs.moved) { resetCycle();
|
|
2480
|
+
if (bs.moved) { resetCycle();
|
|
2481
|
+
const memberIds = membersInRect(bs.x, bs.y, e.clientX, e.clientY);
|
|
2482
|
+
const connIds = connsInRect(bs.x, bs.y, e.clientX, e.clientY);
|
|
2483
|
+
if (!memberIds.length && connIds.length === 1) selectWholeConn(connIds[0]); // a lone connection framed → full component select (envelope + inspector), same as a click
|
|
2484
|
+
else { resetConnState(); const ids = [...memberIds, ...connIds.flatMap((c) => connChildIds(c))]; if (api && api.onSelectMany) api.onSelectMany(ids); } // members and/or several connections → a plain multi-select that INCLUDES the connections' parts
|
|
2485
|
+
}
|
|
2465
2486
|
else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey); // click in empty space → cycle-pick (may land on a derived part) or clear
|
|
2466
2487
|
downXY = null; return;
|
|
2467
2488
|
}
|
|
@@ -647,6 +647,11 @@
|
|
|
647
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>
|
|
648
648
|
<script>
|
|
649
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)) : '';
|
|
650
655
|
let C, PAL, WT;
|
|
651
656
|
let TARGET_CONF = null; // the app's target_confidence input default (%), or null when undeclared — drives the chip's "· target N%" goal
|
|
652
657
|
let view3d = false, view3dReady = false; // 2D|3D toggle state; view3dReady once Steel3DView.init has run
|
|
@@ -667,7 +672,7 @@ function showEmpty(icon, headline, bodyText, appIdText) {
|
|
|
667
672
|
}
|
|
668
673
|
async function boot() {
|
|
669
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; }
|
|
670
|
-
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID));
|
|
675
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS);
|
|
671
676
|
if (!res.ok) {
|
|
672
677
|
if (res.status === 404) {
|
|
673
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);
|
|
@@ -928,7 +933,7 @@ function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),
|
|
|
928
933
|
// fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
|
|
929
934
|
async function persistServer(){try{
|
|
930
935
|
lastLocalPut = Date.now();
|
|
931
|
-
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)});
|
|
932
937
|
setSaved(res.ok?'ok':'err');
|
|
933
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
|
|
934
939
|
}catch(e){setSaved('err');console.error('server save failed',e);}}
|
|
@@ -940,7 +945,7 @@ function scheduleSave(){setSaved('dirty');clearTimeout(saveT);saveT=setTimeout((
|
|
|
940
945
|
window.flushContract = async () => {
|
|
941
946
|
clearTimeout(saveT);
|
|
942
947
|
lastLocalPut = Date.now();
|
|
943
|
-
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), {
|
|
948
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS, {
|
|
944
949
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
|
|
945
950
|
});
|
|
946
951
|
setSaved(res.ok ? 'ok' : 'err');
|
|
@@ -2853,7 +2858,7 @@ async function detailRequest(intent,place,note){
|
|
|
2853
2858
|
: 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
|
|
2854
2859
|
const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
|
|
2855
2860
|
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
2856
|
-
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})});
|
|
2857
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');
|
|
2858
2863
|
}catch(_){toast('Could not queue the request');}}
|
|
2859
2864
|
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
@@ -3976,6 +3981,7 @@ document.getElementById('askAiSend').onclick = async () => {
|
|
|
3976
3981
|
headers: { 'content-type': 'application/json' },
|
|
3977
3982
|
body: JSON.stringify({
|
|
3978
3983
|
appId: APP_ID,
|
|
3984
|
+
project: PROJECT || undefined,
|
|
3979
3985
|
instruction,
|
|
3980
3986
|
snapshots: askAiSnapshots.map(s => ({ name: s.name, dataUrl: s.dataUrl })),
|
|
3981
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; }
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* workspaces.js — the Workflows | Workspaces mode shell (slice 1).
|
|
3
|
+
* Owns: mode state, the landing project grid, the open-project view (Drawings |
|
|
4
|
+
* Model iframes), the project picker + lifecycle menu, and per-project Approve.
|
|
5
|
+
* Composes with app.js/panels.js by CLASS-GATING (CSS hides Workflows chrome under
|
|
6
|
+
* .app.mode-workspaces) — it never removes their DOM or unwires their handlers.
|
|
7
|
+
* Reuses app.js globals (escapeHtml, escapeAttr, showToast) and the flolessBridge
|
|
8
|
+
* seams (api, formModal, loadRequests) like panels.js does.
|
|
9
|
+
* SECURITY CONTRACT (same as panels.js): every innerHTML interpolation passes
|
|
10
|
+
* escapeHtml/escapeAttr — project names are user-authored; no unescaped string may
|
|
11
|
+
* ever reach the DOM. textContent wherever no markup is needed.
|
|
12
|
+
* See docs/superpowers/specs/2026-07-03-workspaces-two-mode-shell-design.md.
|
|
13
|
+
* ========================================================================== */
|
|
14
|
+
(() => {
|
|
15
|
+
const bridge = window.flolessBridge || {};
|
|
16
|
+
const api = bridge.api || (async (path, opts) => {
|
|
17
|
+
const res = await fetch(path, { headers: { 'content-type': 'application/json' }, ...opts });
|
|
18
|
+
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
19
|
+
if (!res.ok || body.ok === false) { const e = new Error(body.error || `HTTP ${res.status}`); e.body = body; throw e; }
|
|
20
|
+
return body;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const $app = document.getElementById('app');
|
|
24
|
+
const $switch = document.getElementById('mode-switch');
|
|
25
|
+
const $landing = document.getElementById('ws-landing');
|
|
26
|
+
const $project = document.getElementById('ws-project');
|
|
27
|
+
const $spine = document.getElementById('ws-spine');
|
|
28
|
+
const $projName = document.getElementById('ws-proj-name');
|
|
29
|
+
const $crumbName = document.getElementById('ws-crumb-name');
|
|
30
|
+
const $status = document.getElementById('ws-status');
|
|
31
|
+
const $stepTabs = document.getElementById('ws-step-tabs');
|
|
32
|
+
const $projMenu = document.getElementById('ws-proj-menu');
|
|
33
|
+
const frames = {
|
|
34
|
+
model: document.getElementById('ws-frame-model'),
|
|
35
|
+
drawings: document.getElementById('ws-frame-drawings'),
|
|
36
|
+
};
|
|
37
|
+
if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
|
|
38
|
+
|
|
39
|
+
// The picker menu is a `.menu` (display:none by default; `.menu.show` → display:block, which
|
|
40
|
+
// OVERRIDES the `hidden` attribute). So close must clear BOTH — same idiom as the ext-menu.
|
|
41
|
+
const closeProjMenu = () => { $projMenu.hidden = true; $projMenu.classList.remove('show'); };
|
|
42
|
+
|
|
43
|
+
const LS_MODE = 'floless:mode';
|
|
44
|
+
const WORKSPACE_APP = 'steel-model'; // slice 1: the one workspace app; a manifest flag generalizes this in slice 5
|
|
45
|
+
let mode = 'workflows';
|
|
46
|
+
try { if (localStorage.getItem(LS_MODE) === 'workspaces') mode = 'workspaces'; } catch { /* private mode */ }
|
|
47
|
+
let projects = [];
|
|
48
|
+
let current = null; // the open project (null = landing)
|
|
49
|
+
|
|
50
|
+
// ── mode ────────────────────────────────────────────────────────────────────
|
|
51
|
+
function applyMode() {
|
|
52
|
+
const ws = mode === 'workspaces';
|
|
53
|
+
$app.classList.toggle('mode-workspaces', ws);
|
|
54
|
+
$switch.querySelectorAll('button').forEach((b) => {
|
|
55
|
+
const on = b.dataset.mode === mode;
|
|
56
|
+
b.classList.toggle('active', on);
|
|
57
|
+
b.setAttribute('aria-selected', String(on));
|
|
58
|
+
});
|
|
59
|
+
$landing.hidden = !ws || !!current;
|
|
60
|
+
$project.hidden = !ws || !current;
|
|
61
|
+
$spine.hidden = !ws || !current;
|
|
62
|
+
if (!ws && !$projMenu.hidden) closeProjMenu();
|
|
63
|
+
if (ws && !current) loadProjects();
|
|
64
|
+
}
|
|
65
|
+
function setMode(m) {
|
|
66
|
+
mode = m === 'workspaces' ? 'workspaces' : 'workflows';
|
|
67
|
+
try { localStorage.setItem(LS_MODE, mode); } catch { /* private mode */ }
|
|
68
|
+
applyMode();
|
|
69
|
+
}
|
|
70
|
+
$switch.addEventListener('click', (e) => {
|
|
71
|
+
const b = e.target.closest('button[data-mode]');
|
|
72
|
+
if (b) setMode(b.dataset.mode);
|
|
73
|
+
});
|
|
74
|
+
document.addEventListener('keydown', (e) => {
|
|
75
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
|
76
|
+
const t = e.target;
|
|
77
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
78
|
+
if (e.key === '1') { e.preventDefault(); setMode('workflows'); }
|
|
79
|
+
else if (e.key === '2') { e.preventDefault(); setMode('workspaces'); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── landing ─────────────────────────────────────────────────────────────────
|
|
83
|
+
const relAgo = (iso) => {
|
|
84
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
85
|
+
if (isNaN(ms) || ms < 0) return '';
|
|
86
|
+
const min = Math.round(ms / 60000);
|
|
87
|
+
if (min < 1) return 'just now';
|
|
88
|
+
if (min < 60) return `${min} min ago`;
|
|
89
|
+
const h = Math.round(min / 60);
|
|
90
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async function loadProjects() {
|
|
94
|
+
// Surface a real failure — never present a server/network error as an empty workspace
|
|
95
|
+
// (the empty-state CTA would be a lie: "no projects yet" when the truth is "load failed").
|
|
96
|
+
try { ({ projects } = await api('/api/projects')); }
|
|
97
|
+
catch (err) { projects = []; showToast('Couldn’t load projects: ' + (err && err.message || err), 'warn'); }
|
|
98
|
+
renderLanding();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderLanding() {
|
|
102
|
+
let html = `<div class="ws-head"><h2>Workspaces</h2>` +
|
|
103
|
+
`<div class="ws-sub">Live projects — open a document to keep working. No Run here.</div></div>` +
|
|
104
|
+
`<div class="ws-grid">`;
|
|
105
|
+
for (const p of projects) {
|
|
106
|
+
html += `<div class="ws-card" role="button" tabindex="0" data-open="${escapeAttr(p.id)}">` +
|
|
107
|
+
`<span class="ws-thumb"><span class="ws-kind">${escapeHtml(p.app)}</span>▦</span>` +
|
|
108
|
+
`<span class="ws-body"><span><span class="ws-name">${escapeHtml(p.name)}</span>` +
|
|
109
|
+
`<span class="ws-meta">${escapeHtml(p.app)} · <span class="t">${escapeHtml(relAgo(p.updatedAt))}</span></span></span>` +
|
|
110
|
+
`<button type="button" class="ws-kebab" data-kebab="${escapeAttr(p.id)}" aria-label="Project actions" data-tip="Rename · Duplicate · Archive">⋯</button>` +
|
|
111
|
+
`</span></div>`;
|
|
112
|
+
}
|
|
113
|
+
// Slice-1 creation path: seed a project from the app's current takeoff. The
|
|
114
|
+
// compose-time "read a drawing set" flow is Slice 4 — this card is honest about that.
|
|
115
|
+
html += `<button type="button" class="ws-seed" id="ws-seed">` +
|
|
116
|
+
`<span class="plus">+</span><span>Import the current ${escapeHtml(WORKSPACE_APP)} takeoff as a project</span></button>`;
|
|
117
|
+
html += `</div>`;
|
|
118
|
+
if (!projects.length) html += `<div class="ws-empty-note">No projects yet — import your current takeoff above, or ask your terminal AI to read a drawing set.</div>`;
|
|
119
|
+
$landing.innerHTML = html;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
$landing.addEventListener('click', async (e) => {
|
|
123
|
+
const kebab = e.target.closest('[data-kebab]');
|
|
124
|
+
if (kebab) { e.stopPropagation(); openProjMenu(kebab.dataset.kebab, kebab); return; }
|
|
125
|
+
const card = e.target.closest('[data-open]');
|
|
126
|
+
if (card) { openProject(card.dataset.open); return; }
|
|
127
|
+
if (e.target.closest('#ws-seed')) createSeeded();
|
|
128
|
+
});
|
|
129
|
+
$landing.addEventListener('keydown', (e) => {
|
|
130
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
131
|
+
const card = e.target.closest('[data-open]');
|
|
132
|
+
if (card) { e.preventDefault(); openProject(card.dataset.open); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function promptName(title, label, value) {
|
|
136
|
+
if (!bridge.formModal) return null;
|
|
137
|
+
const res = await bridge.formModal({ title, fields: [{ name: 'name', label, value, placeholder: 'e.g. Westfield Retail — Ph2' }], okLabel: 'Save' });
|
|
138
|
+
return res && typeof res.name === 'string' && res.name.trim() ? res.name.trim() : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function createSeeded() {
|
|
142
|
+
const name = await promptName('New project', 'Name this project', '');
|
|
143
|
+
if (!name) return;
|
|
144
|
+
try {
|
|
145
|
+
const { project, seeded } = await api('/api/projects', { method: 'POST', body: JSON.stringify({ name, app: WORKSPACE_APP, seedFromApp: true }) });
|
|
146
|
+
// Tell the truth about the import: seeded=false means there was no current takeoff to copy
|
|
147
|
+
// (or it was unreadable) — the project is EMPTY, don't imply the takeoff came across.
|
|
148
|
+
if (seeded) showToast(`Project "${project.name}" created from the current ${WORKSPACE_APP} takeoff`, 'ok');
|
|
149
|
+
else showToast(`Project "${project.name}" created — empty (no current ${WORKSPACE_APP} takeoff to import)`, 'warn');
|
|
150
|
+
projects.unshift(project);
|
|
151
|
+
openProject(project.id);
|
|
152
|
+
} catch (err) { showToast('Couldn’t create the project: ' + (err && err.message || err), 'warn'); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── open project ────────────────────────────────────────────────────────────
|
|
156
|
+
async function openProject(id) {
|
|
157
|
+
let p = projects.find((x) => x.id === id);
|
|
158
|
+
if (!p) { try { p = (await api('/api/projects')).projects.find((x) => x.id === id); } catch { /* fall through */ } }
|
|
159
|
+
if (!p) { showToast('Project not found — it may have been archived.', 'warn'); return loadProjects(); }
|
|
160
|
+
current = p;
|
|
161
|
+
$projName.textContent = p.name;
|
|
162
|
+
$crumbName.textContent = p.name;
|
|
163
|
+
$status.textContent = p.app;
|
|
164
|
+
// Lazily src the frames once per project; switching steps only toggles hidden so the
|
|
165
|
+
// editor's 3D state survives a Drawings⇄Model round-trip.
|
|
166
|
+
const qs = `?app=${encodeURIComponent(p.app)}&project=${encodeURIComponent(p.id)}`;
|
|
167
|
+
frames.model.dataset.want = `/steel-editor.html${qs}`;
|
|
168
|
+
frames.drawings.dataset.want = `/steel-filter.html${qs}`;
|
|
169
|
+
frames.model.src = 'about:blank'; frames.drawings.src = 'about:blank'; // reset any previous project
|
|
170
|
+
frames.model.dataset.loaded = ''; frames.drawings.dataset.loaded = '';
|
|
171
|
+
setStep('model');
|
|
172
|
+
applyMode();
|
|
173
|
+
}
|
|
174
|
+
function closeProject() { current = null; closeProjMenu(); applyMode(); }
|
|
175
|
+
document.getElementById('ws-back').addEventListener('click', closeProject);
|
|
176
|
+
|
|
177
|
+
function setStep(s) {
|
|
178
|
+
$stepTabs.querySelectorAll('button').forEach((b) => {
|
|
179
|
+
const on = b.dataset.step === s;
|
|
180
|
+
b.classList.toggle('active', on);
|
|
181
|
+
b.setAttribute('aria-selected', String(on));
|
|
182
|
+
});
|
|
183
|
+
for (const [k, f] of Object.entries(frames)) {
|
|
184
|
+
const show = k === s;
|
|
185
|
+
f.hidden = !show;
|
|
186
|
+
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
$stepTabs.addEventListener('click', (e) => {
|
|
190
|
+
const b = e.target.closest('button[data-step]');
|
|
191
|
+
if (b) setStep(b.dataset.step);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Approve (per-project) ───────────────────────────────────────────────────
|
|
195
|
+
document.getElementById('ws-approve').addEventListener('click', async () => {
|
|
196
|
+
if (!current) return;
|
|
197
|
+
const btn = document.getElementById('ws-approve');
|
|
198
|
+
btn.disabled = true;
|
|
199
|
+
const prev = btn.textContent;
|
|
200
|
+
btn.textContent = '◆ Approving…';
|
|
201
|
+
try {
|
|
202
|
+
// Flush the editor's debounced autosave FIRST (same-origin iframe) so Approve bakes the
|
|
203
|
+
// LATEST edits, not a stale contract — a user can edit then hit Approve before the ~debounce
|
|
204
|
+
// fires. flushContract() PUTs the live contract and resolves; a save failure aborts the bake.
|
|
205
|
+
const win = frames.model && frames.model.contentWindow;
|
|
206
|
+
if (win && typeof win.flushContract === 'function') await win.flushContract();
|
|
207
|
+
await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
208
|
+
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
showToast('Approve failed: ' + (err && err.message || err), 'warn');
|
|
211
|
+
} finally { btn.disabled = false; btn.textContent = prev; }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|
|
215
|
+
let menuFor = null;
|
|
216
|
+
function menuItem(action, label) {
|
|
217
|
+
return `<button class="menu-item" data-act="${action}" role="menuitem"><span class="menu-label">${escapeHtml(label)}</span></button>`;
|
|
218
|
+
}
|
|
219
|
+
function openProjMenu(id, anchor) {
|
|
220
|
+
menuFor = id;
|
|
221
|
+
const others = projects.filter((p) => p.id !== id);
|
|
222
|
+
$projMenu.innerHTML =
|
|
223
|
+
(others.length ? others.map((p) => `<button class="menu-item" data-goto="${escapeAttr(p.id)}" role="menuitem"><span class="menu-label">Open: ${escapeHtml(p.name)}</span></button>`).join('') + '<div class="menu-divider"></div>' : '') +
|
|
224
|
+
menuItem('rename', 'Rename…') + menuItem('duplicate', 'Duplicate') +
|
|
225
|
+
'<div class="menu-divider"></div>' + menuItem('archive', 'Archive');
|
|
226
|
+
const r = anchor.getBoundingClientRect();
|
|
227
|
+
$projMenu.style.left = Math.round(Math.max(8, Math.min(r.left, window.innerWidth - 260))) + 'px';
|
|
228
|
+
$projMenu.style.top = Math.round(r.bottom + 6) + 'px';
|
|
229
|
+
$projMenu.hidden = false;
|
|
230
|
+
$projMenu.classList.add('show');
|
|
231
|
+
}
|
|
232
|
+
document.getElementById('ws-proj-trigger').addEventListener('click', (e) => {
|
|
233
|
+
if (!current) return;
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
if ($projMenu.hidden) openProjMenu(current.id, e.currentTarget); else closeProjMenu();
|
|
236
|
+
});
|
|
237
|
+
document.addEventListener('click', (e) => {
|
|
238
|
+
if ($projMenu.hidden) return;
|
|
239
|
+
if (!$projMenu.contains(e.target) && !e.target.closest('#ws-proj-trigger') && !e.target.closest('[data-kebab]')) closeProjMenu();
|
|
240
|
+
});
|
|
241
|
+
$projMenu.addEventListener('click', async (e) => {
|
|
242
|
+
const goto = e.target.closest('[data-goto]');
|
|
243
|
+
if (goto) { closeProjMenu(); return openProject(goto.dataset.goto); }
|
|
244
|
+
const act = e.target.closest('[data-act]');
|
|
245
|
+
if (!act || !menuFor) return;
|
|
246
|
+
closeProjMenu();
|
|
247
|
+
const id = menuFor;
|
|
248
|
+
const proj = projects.find((p) => p.id === id);
|
|
249
|
+
try {
|
|
250
|
+
if (act.dataset.act === 'rename') {
|
|
251
|
+
const name = await promptName('Rename project', 'New name', proj ? proj.name : '');
|
|
252
|
+
if (!name) return;
|
|
253
|
+
const { project } = await api(`/api/projects/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name }) });
|
|
254
|
+
if (current && current.id === id) { current = project; $projName.textContent = project.name; $crumbName.textContent = project.name; }
|
|
255
|
+
showToast('Renamed', 'ok');
|
|
256
|
+
} else if (act.dataset.act === 'duplicate') {
|
|
257
|
+
const { project } = await api(`/api/projects/${encodeURIComponent(id)}/duplicate`, { method: 'POST', body: '{}' });
|
|
258
|
+
showToast(`Duplicated as "${project.name}"`, 'ok');
|
|
259
|
+
} else if (act.dataset.act === 'archive') {
|
|
260
|
+
await api(`/api/projects/${encodeURIComponent(id)}/archive`, { method: 'POST', body: '{}' });
|
|
261
|
+
showToast('Archived — recoverable from ~/.floless/projects/.archive', 'ok');
|
|
262
|
+
if (current && current.id === id) { closeProject(); return; }
|
|
263
|
+
}
|
|
264
|
+
} catch (err) { showToast('Action failed: ' + (err && err.message || err), 'warn'); }
|
|
265
|
+
loadProjects();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
window.flolessWorkspaces = { setMode, refresh: loadProjects };
|
|
269
|
+
applyMode(); // restore persisted mode immediately (matches panels.js applyView timing)
|
|
270
|
+
})();
|