@floless/app 0.71.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>
@@ -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
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {