@floless/app 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.35.0" : void 0,
52859
+ define: true ? "0.36.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.35.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.36.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -53759,6 +53759,10 @@ function bakeContractIntoApp(sourcePath, contract) {
53759
53759
  // contract-to-scene.ts
53760
53760
  var FT_TO_MM = 304.8;
53761
53761
  var IN_TO_MM = 25.4;
53762
+ function depthPosition(explicit, dzMm, horizMm) {
53763
+ if (explicit === "top" || explicit === "middle" || explicit === "bottom") return explicit;
53764
+ return dzMm > horizMm * 0.5774 ? "middle" : "top";
53765
+ }
53762
53766
  function profileDims(profile) {
53763
53767
  if (!profile) return null;
53764
53768
  const p = profile.trim().toUpperCase();
@@ -53826,8 +53830,10 @@ function contractToScene(contractInput) {
53826
53830
  } else {
53827
53831
  const z0 = m.ends?.[0]?.tos != null ? m.ends[0].tos * IN_TO_MM : defaultTosMm;
53828
53832
  const z1 = m.ends?.[1]?.tos != null ? m.ends[1].tos * IN_TO_MM : defaultTosMm;
53829
- from = [ax, ay, z0];
53830
- to = [bx, by, z1];
53833
+ const pos = depthPosition(m.position, Math.abs(z1 - z0), Math.hypot(bx - ax, by - ay));
53834
+ const zoff = pos === "top" ? -dims.d / 2 : pos === "bottom" ? dims.d / 2 : 0;
53835
+ from = [ax, ay, z0 + zoff];
53836
+ to = [bx, by, z1 + zoff];
53831
53837
  }
53832
53838
  elements.push({ id: m.id, group: profile, kind: "box", from, to, section: { w: dims.w, d: dims.d }, meta: { profile } });
53833
53839
  }
@@ -58653,6 +58659,8 @@ async function startServer() {
58653
58659
  });
58654
58660
  app.post(
58655
58661
  "/api/contract/:appId/score",
58662
+ { bodyLimit: 25 * 1024 * 1024 },
58663
+ // contracts embed rasters (matches /scene + PUT) — the editor/host-AI POSTs the live contract to score
58656
58664
  async (req, reply) => {
58657
58665
  const doc2 = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
58658
58666
  if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to score" });
@@ -58682,6 +58690,8 @@ async function startServer() {
58682
58690
  );
58683
58691
  app.post(
58684
58692
  "/api/contract/:appId/scene",
58693
+ { bodyLimit: 25 * 1024 * 1024 },
58694
+ // contracts embed rasters (matches PUT /api/contract) — the editor POSTs the live contract on every 3D rebuild
58685
58695
  async (req, reply) => {
58686
58696
  const doc2 = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
58687
58697
  if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
@@ -125,6 +125,7 @@
125
125
  },
126
126
  "angle": { "enum": ["H", "V", "D"] },
127
127
  "role": { "enum": ["beam", "column"], "description": "Defaults to beam when absent." },
128
+ "position": { "enum": ["top", "middle", "bottom"], "description": "Profile placement relative to the reference line (the work-line between the two work-points), like Tekla's depth position. top = line at the section top (steel hangs below); middle = line through the centroid; bottom = line at the section bottom. When absent the editor defaults a flat beam to top (line = top of steel) and a column / steep brace to middle." },
128
129
  "rfi": { "type": "boolean", "description": "True when the size is unresolved (excluded from the BOM)." },
129
130
  "mf": { "type": "boolean", "description": "Moment-frame member (persistent flag)." },
130
131
  "ends": {
@@ -222,6 +222,14 @@ Important optional fields:
222
222
  Key field rules:
223
223
  - All `wp` / `disp` / `a` / `b` coordinates are **display space** (not native PDF coords, not
224
224
  millimetres).
225
+ - **Endpoint direction (canonical order).** `wp[0]`/`ends[0]` is the member's **START** (drawn as a
226
+ **yellow** dot in the editor); `wp[1]`/`ends[1]` is the **END** (drawn **magenta**). Always emit
227
+ endpoints in a consistent direction so start/end read the same across the whole takeoff:
228
+ - **Columns** — bottom → top (start at the lower elevation): `wp`/`col` go `bos` (start) → `tos` (end).
229
+ - **Beams** in plan — **left → right** (start = smaller X); for a vertical-in-plan run, **bottom → up**
230
+ (start = the lower end on the sheet, i.e. the **larger** display-Y since display Y grows downward).
231
+ This is the on-screen reading direction (→ or ↑). It costs nothing to get right at authoring time and
232
+ makes the yellow/magenta end handles meaningful in both the 2D and 3D editor.
225
233
  - Elevations are **decimal inches** (canonical); the editor accepts/displays ft-in strings.
226
234
  `tosDef: false` means the value came from a drawing callout; `true` means it follows the plan
227
235
  default.
@@ -88,8 +88,10 @@ machine via a `self_test:true` watch fixture — no Tekla, no `--simulate`:
88
88
  - **The installed SEA server usually already holds :4317** (and it 404s `/api/status` — different
89
89
  build). Don't fight it: `index.ts` honors `PORT`, so run the dev server on a free port —
90
90
  `PORT=4318 FLOLESS_LICENSE_DIR="$LOCALAPPDATA/FlolessApp-data" npx tsx main.ts --serve` (from
91
- `server/`, background). Without the license dir it serves the **sign-in gate** (no `#routines-btn`)
92
- — assert `document.getElementById('routines-btn')` exists before driving the panel.
91
+ `server/`, background). Without the license dir it serves the **sign-in gate** (the workspace
92
+ chrome is absent) — assert a stable workspace element like `document.getElementById('run-btn')`
93
+ (or `#menu-btn`) exists before driving the UI. (Don't probe `#routines-btn`/`#browse-btn` —
94
+ those header buttons moved into the ≡ menu in #149.)
93
95
  - **The Playwright MCP browser is flaky/locked here** ("Browser is already in use…"). A **standalone
94
96
  Node script is more reliable**: `playwright-core` resolves at
95
97
  `C:/Users/Pawel/AppData/Roaming/npm/node_modules/@playwright/cli/node_modules/playwright-core`;
package/dist/web/app.css CHANGED
@@ -115,7 +115,7 @@
115
115
  reachable by scrolling), at viewport widths narrower than the design (#53). */
116
116
  min-width: 0;
117
117
  }
118
- /* Brand + view-toggle stay full size; .controls is the shrink sink instead. */
118
+ /* The ≡ menu + brand stay full size; .controls is the shrink sink instead. */
119
119
  .header-left { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
120
120
  .brand { display: flex; align-items: center; gap: 12px; }
121
121
  .brand .mark {
@@ -169,10 +169,8 @@
169
169
  outline: 2px solid var(--accent);
170
170
  outline-offset: 2px;
171
171
  }
172
- /* Agents = utility (opens a reference modal, not a workflow step): ghost button,
173
- quietest tier. Label kept an unlabeled icon would be an unclear affordance. */
174
- #browse-btn { background: transparent; border-color: transparent; color: var(--text-dim); }
175
- #browse-btn:hover { background: var(--surface-2); border-color: var(--border-strong); color: var(--text); }
172
+ /* (The #browse-btn "Agents" toolbar button moved into the menu in #149; its
173
+ ghost-tier styles were removed with it.) */
176
174
  /* Simulate mirrors Compile (outline-accent tier) — a consistent secondary
177
175
  sibling; Run stays the only filled (primary) button, so the two outline
178
176
  buttons reading alike is the correct hierarchy, not a lost signal. */
@@ -1490,10 +1488,8 @@
1490
1488
  #confirm-dont-save:hover { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 10%, transparent); }
1491
1489
 
1492
1490
  /* ========== ROUTINES ========== */
1493
- /* Header entry point: same ghost tier as #browse-btn (Agents) a utility that
1494
- opens a management modal, not a workflow step. */
1495
- #routines-btn { background: transparent; border-color: transparent; color: var(--text-dim); }
1496
- #routines-btn:hover { background: var(--surface-2); border-color: var(--border-strong); color: var(--text); }
1491
+ /* (The #routines-btn header button moved into the menu in #149; the panel is
1492
+ opened from there and via Ctrl+R, so its toolbar styles were removed.) */
1497
1493
 
1498
1494
  .modal.routines { width: 600px; max-width: 92vw; max-height: 86vh; display: flex; flex-direction: column; }
1499
1495
  .rtn-quota { font-size: 11px; color: var(--text-dim); margin-bottom: 10px; flex: 0 0 auto; }
@@ -1787,6 +1783,21 @@
1787
1783
  transition: all 0.15s;
1788
1784
  margin-right: 10px;
1789
1785
  flex-shrink: 0;
1786
+ position: relative; /* anchors the dashboard-updated badge below */
1787
+ }
1788
+ /* "Dashboard updated" badge — panels.js lights this when dashboard panels change while
1789
+ the user is on Canvas, so the signal stays visible without opening the ≡ menu (#149,
1790
+ the old Dashboard-toggle dot's new home). Cleared when the dashboard is viewed. */
1791
+ .hamburger.has-dash-update::after {
1792
+ content: "";
1793
+ position: absolute;
1794
+ top: 3px;
1795
+ right: 3px;
1796
+ width: 6px;
1797
+ height: 6px;
1798
+ border-radius: 50%;
1799
+ background: var(--accent);
1800
+ box-shadow: 0 0 0 2px var(--bg);
1790
1801
  }
1791
1802
  .hamburger:hover {
1792
1803
  border-color: var(--accent-dim);
@@ -2881,20 +2892,10 @@ body {
2881
2892
  shipped UI. No new colors, fonts, or aesthetics here — composition only.
2882
2893
  ========================================================================== */
2883
2894
 
2884
- /* Header view switch (Canvas | Dashboard) compact segmented control next to
2885
- the brand; reuses the .rtn-mode pill vocabulary at header scale. */
2886
- .view-toggle { display: inline-flex; gap: 4px; margin-left: 16px; }
2887
- .view-btn {
2888
- background: var(--surface-2); border: 1px solid var(--border-strong);
2889
- color: var(--text-dim); font-size: 10.5px; text-transform: uppercase;
2890
- letter-spacing: 0.1em; padding: 4px 12px; border-radius: 6px; cursor: pointer;
2891
- transition: color 0.15s, border-color 0.15s, background 0.15s;
2892
- }
2893
- .view-btn:hover { color: var(--text); border-color: var(--accent-dim); }
2894
- .view-btn.active { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-soft); }
2895
- .view-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2896
- /* "Dashboard updated" dot — lit when panels changed while the user was on Canvas. */
2897
- .view-dot { color: var(--accent); font-size: 8px; vertical-align: 2px; margin-left: 5px; }
2895
+ /* The Canvas|Dashboard segmented toggle moved into the menu in #149; its styles are
2896
+ gone. The "dashboard updated" dot lives on now: in the menu's view-switch item (.view-dot)
2897
+ and as the hamburger badge (.hamburger.has-dash-update, defined above). */
2898
+ .view-dot { color: var(--accent); font-size: 8px; vertical-align: 2px; margin-left: auto; padding-left: 6px; }
2898
2899
  .view-dot[hidden] { display: none; }
2899
2900
 
2900
2901
  /* Customized pill — present only while custom panels exist; opens the ext menu. */
package/dist/web/app.js CHANGED
@@ -50,7 +50,6 @@ function saveCollapse() {
50
50
  const $app = document.getElementById('app');
51
51
  const $promptSel = document.getElementById('prompt-select');
52
52
  const $runBtn = document.getElementById('run-btn');
53
- const $browseBtn = document.getElementById('browse-btn');
54
53
  const $messages = document.getElementById('messages');
55
54
  const $topology = document.getElementById('topology');
56
55
  const $tabs = document.getElementById('tabs');
@@ -952,8 +951,6 @@ $runBtn.onclick = async () => {
952
951
  appendNarration(`Execution complete · ${nodeIds(p).length} steps fired · check the <strong>Execution</strong> tab on each agent for the full trace.`);
953
952
  };
954
953
 
955
- $browseBtn.onclick = openLibrary;
956
-
957
954
  document.querySelectorAll('.panel-toggle').forEach(btn => {
958
955
  btn.onclick = () => toggleCollapse(btn.dataset.side);
959
956
  });
@@ -1050,6 +1047,7 @@ function handleMenuAction(action) {
1050
1047
  case 'save-as': doSaveAs(); break;
1051
1048
  case 'find': openFind(); break;
1052
1049
  case 'integrations': openIntegrations(); break;
1050
+ case 'agents': openLibrary(); break;
1053
1051
  case 'routines': openRoutines(); break;
1054
1052
  case 'report-issue': openReportIssue(); break;
1055
1053
  case 'fullscreen': toggleFullscreen(); break;
@@ -1546,6 +1544,12 @@ document.addEventListener('keydown', (e) => {
1546
1544
  if (cmd && !e.shiftKey && e.key.toLowerCase() === 's') { e.preventDefault(); doSave(); return; }
1547
1545
  if (cmd && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); return; }
1548
1546
  if (cmd && e.key.toLowerCase() === 'i') { e.preventDefault(); openIntegrations(); return; }
1547
+ // Agents and Routines — the panels reachable from the ≡ menu since #149 moved their
1548
+ // toolbar buttons there. Use the same Ctrl-or-Cmd modifier (`cmd`) as Ctrl+O/S/F/I
1549
+ // above, so on macOS these are Cmd+G/Cmd+R. The !e.shiftKey guard leaves the Shift+
1550
+ // hard-reload (Ctrl/Cmd+Shift+R) untouched. Both override a browser default, as O/S do.
1551
+ if (cmd && !e.shiftKey && e.key.toLowerCase() === 'g') { e.preventDefault(); openLibrary(); return; }
1552
+ if (cmd && !e.shiftKey && e.key.toLowerCase() === 'r') { e.preventDefault(); openRoutines(); return; }
1549
1553
  if (cmd && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomBy(+1); return; }
1550
1554
  if (cmd && e.key === '-') { e.preventDefault(); zoomBy(-1); return; }
1551
1555
  if (cmd && e.key === '0') { e.preventDefault(); resetView(); autoFit = false; return; }
package/dist/web/aware.js CHANGED
@@ -1848,8 +1848,16 @@
1848
1848
  // `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
1849
1849
  // { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
1850
1850
  const $formModal = document.getElementById('form-modal');
1851
+ // The styled modal is a single shared #form-modal. Opening a second one while one is
1852
+ // already up would clobber the first's button/keydown handlers and strand it (#148).
1853
+ // Refuse re-entry: the second caller resolves null (treated as "cancelled") and aborts,
1854
+ // and warns so the drop is observable rather than a silent no-op. The flag is set at the
1855
+ // END of setup (not here) so a synchronous setup error can't latch it shut for the whole
1856
+ // session; done() clears it on every close.
1857
+ let _formModalOpen = false;
1851
1858
  function formModal({ title, sub = '', fields, okLabel = 'Save', danger = false }) {
1852
1859
  return new Promise((resolve) => {
1860
+ if (_formModalOpen) { console.warn('formModal: a modal is already open — refusing re-entry'); resolve(null); return; }
1853
1861
  const $title = document.getElementById('form-modal-title');
1854
1862
  const $sub = document.getElementById('form-modal-sub');
1855
1863
  const $body = document.getElementById('form-modal-body');
@@ -2017,6 +2025,7 @@
2017
2025
  return out;
2018
2026
  };
2019
2027
  const done = (result) => {
2028
+ _formModalOpen = false;
2020
2029
  hideModal($formModal);
2021
2030
  $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
2022
2031
  $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
@@ -2032,6 +2041,10 @@
2032
2041
  if (e.key === 'Escape') { e.preventDefault(); done(null); }
2033
2042
  else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
2034
2043
  };
2044
+ // Setup succeeded and the modal is shown — mark it open now. Setting it here (not
2045
+ // before the synchronous setup above) means a setup throw can't latch the guard for
2046
+ // the whole session; done() clears it on close.
2047
+ _formModalOpen = true;
2035
2048
  });
2036
2049
  }
2037
2050
 
@@ -2689,6 +2702,11 @@
2689
2702
  // installed build exists, show a footer pill → click opens the release-notes
2690
2703
  // popover → Update now → POST /api/update/apply, which downloads + relaunches into
2691
2704
  // the new version. Hidden in dev/npm (supported:false).
2705
+ // Mutual lock across the two footer update flows (#148). A floless self-update downloads
2706
+ // + relaunches the app; an AWARE upgrade reinstalls @aware-aeco/cli in place. Running both
2707
+ // at once can relaunch mid-install or strand a pill on a phantom "↑ Updating…". Only one
2708
+ // update may be in flight at a time — held from the confirm dialog through to completion.
2709
+ let _updateApplying = null; // 'app' | 'aware' | null
2692
2710
  const $appUpdate = document.getElementById('app-update');
2693
2711
  let _appUpdateRecoveryTimer = null; // cleared before each new apply attempt
2694
2712
  // The apply body, extracted so the popover's "Update now" can drive it with the
@@ -2704,10 +2722,14 @@
2704
2722
  showToast('Update in your terminal — ' + cmd, 'info');
2705
2723
  return;
2706
2724
  }
2707
- // desktop (Velopack): one-click download + relaunch
2725
+ // desktop (Velopack): one-click download + relaunch. Refuse to start while another
2726
+ // update (or this one) is already in flight, so a back-to-back click can't drive two
2727
+ // updates against the shared confirm modal (#148).
2728
+ if (_updateApplying) { showToast('An update is already in progress — please wait for it to complete.', 'info'); return; }
2729
+ _updateApplying = 'app';
2708
2730
  const v = version || '';
2709
2731
  const go = await formModal({ title: 'Update and relaunch', sub: 'FloLess will download v' + v + ', close, and relaunch into the new version.', fields: [], okLabel: 'Update now' });
2710
- if (!go) return;
2732
+ if (!go) { _updateApplying = null; return; }
2711
2733
  // Record the pending version so the new build's first paint can reveal the
2712
2734
  // what's-new panel (survives the relaunch via localStorage; cleared once shown).
2713
2735
  try { localStorage.setItem('floless.whatsNew.pending', v); } catch { /* private mode */ }
@@ -2727,6 +2749,7 @@
2727
2749
  _appUpdateRecoveryTimer = null;
2728
2750
  if ($appUpdate && $appUpdate.disabled) {
2729
2751
  $appUpdate.disabled = false;
2752
+ _updateApplying = null; // relaunch never happened — release the lock so retry works
2730
2753
  $appUpdate.textContent = '↑ Relaunch failed — retry';
2731
2754
  try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
2732
2755
  refreshUpdate(); // re-poll so the pill state reflects reality, same as catch path
@@ -2734,6 +2757,7 @@
2734
2757
  }, 90_000);
2735
2758
  } catch (e) {
2736
2759
  $appUpdate.disabled = false;
2760
+ _updateApplying = null;
2737
2761
  try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
2738
2762
  showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
2739
2763
  refreshUpdate();
@@ -2808,9 +2832,13 @@
2808
2832
  // affordance and shows a brief "installed" confirmation before the pill hides.
2809
2833
  async function applyAwareUpdate(version) {
2810
2834
  if (!$awareUpdate) return;
2835
+ // One update at a time — see _updateApplying (#148). AWARE stays open on success, so the
2836
+ // lock is released in every terminal path below (success, cancel, error).
2837
+ if (_updateApplying) { showToast('An update is already in progress — please wait for it to complete.', 'info'); return; }
2838
+ _updateApplying = 'aware';
2811
2839
  const v = version || '';
2812
2840
  const go = await formModal({ title: 'Upgrade AWARE runtime', sub: 'Reinstalls @aware-aeco/cli to v' + v + ' in place. The app stays open — the version updates automatically when the install finishes.', fields: [], okLabel: 'Upgrade now' });
2813
- if (!go) return;
2841
+ if (!go) { _updateApplying = null; return; }
2814
2842
  $awareUpdate.disabled = true;
2815
2843
  $awareUpdate.textContent = '↑ Upgrading…';
2816
2844
  try {
@@ -2820,6 +2848,7 @@
2820
2848
  if (!r.ok || !d.ok) throw new Error(d.error || 'aware upgrade failed');
2821
2849
  // Success is silent: the pill disappears and the AWARE version re-stamps live.
2822
2850
  $awareUpdate.disabled = false;
2851
+ _updateApplying = null; // AWARE stays open — release the lock so the app pill can update
2823
2852
  const wv = document.getElementById('aware-version');
2824
2853
  if (wv && d.version) {
2825
2854
  wv.textContent = 'AWARE ' + d.version;
@@ -2836,6 +2865,7 @@
2836
2865
  setTimeout(() => { $awareUpdate.hidden = true; refreshAwareUpdate(); }, 2000);
2837
2866
  } catch (e) {
2838
2867
  $awareUpdate.disabled = false;
2868
+ _updateApplying = null;
2839
2869
  const msg = (e && e.name === 'TimeoutError')
2840
2870
  ? 'AWARE upgrade is taking a while — it may still be installing; the version updates when it finishes'
2841
2871
  : 'AWARE upgrade failed — ' + String((e && e.message) || e).slice(0, 80);
@@ -5253,7 +5283,8 @@
5253
5283
  };
5254
5284
 
5255
5285
  // Wiring (the modal elements are static in index.html, present when this runs).
5256
- document.getElementById('routines-btn').onclick = () => openRoutines();
5286
+ // The header "Routines" button moved into the ≡ menu (#149); it's opened from there
5287
+ // (data-action="routines") and via Ctrl+R, so there's no toolbar button to wire here.
5257
5288
  document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
5258
5289
  onBackdropDismiss($routinesModal, () => hideModal($routinesModal));
5259
5290
  document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
@@ -5455,9 +5486,20 @@
5455
5486
  // app.js already stored the ORIGINAL functions as .onclick/.oninput before we
5456
5487
  // reassigned the globals; rebind via arrows so they resolve our versions at
5457
5488
  // call-time (otherwise Save/library-search silently run the demo code).
5458
- $browseBtn.onclick = () => openLibrary();
5489
+ // The header "⊞ Agents" button moved into the ≡ menu (#149) openLibrary is now
5490
+ // driven from there (data-action="agents") and via Ctrl+G, so no toolbar button to bind.
5459
5491
  $libSearch.oninput = () => renderLibrary();
5460
5492
 
5493
+ // One-time hint: the Agents/Routines buttons moved into the ≡ menu (#149). Fire once
5494
+ // so existing users (muscle memory) and new users both learn where they are + the
5495
+ // shortcuts. Gated by localStorage like the what's-new panel; private mode → skip.
5496
+ try {
5497
+ if (!localStorage.getItem('floless.hint.menuMoved')) {
5498
+ localStorage.setItem('floless.hint.menuMoved', '1');
5499
+ showToast('Agents and Routines live in the ≡ menu — press Ctrl+G or Ctrl+R to open them.', 'info');
5500
+ }
5501
+ } catch { /* private mode — skip the hint */ }
5502
+
5461
5503
  // Agent Library two-tab wiring (Installed | Available). The Available tab is
5462
5504
  // lazy-loaded on first switch (see switchLibTab).
5463
5505
  const $libTabs = document.getElementById('lib-tabs');
@@ -33,13 +33,10 @@
33
33
  </span>
34
34
  <span class="name">FloLess</span>
35
35
  </div>
36
- <!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the
37
- user's custom panels from ~/.floless/ui/extensions.json). The dot on
38
- Dashboard lights when panels changed while the user was on Canvas. -->
39
- <div class="view-toggle" id="view-toggle" role="group" aria-label="Workspace view">
40
- <button type="button" class="view-btn active" data-view="canvas" aria-pressed="true">Canvas</button>
41
- <button type="button" class="view-btn" data-view="dashboard" aria-pressed="false">Dashboard<span class="view-dot" id="dash-dot" hidden aria-label="Dashboard updated">●</span></button>
42
- </div>
36
+ <!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the user's
37
+ custom panels from ~/.floless/ui/extensions.json) moved into the ≡ menu in #149.
38
+ The "dashboard updated" signal now rides the hamburger badge (.has-dash-update)
39
+ and the menu item's dot (#dash-dot, relocated into the menu). -->
43
40
  <!-- Shown only while custom panels exist; opens Undo · History · Reset. -->
44
41
  <button type="button" id="ext-badge" class="ext-badge" hidden aria-haspopup="menu" aria-expanded="false" aria-controls="ext-menu" data-tip="Your dashboard is customized — undo, history, reset to default">Customized</button>
45
42
  </div>
@@ -57,8 +54,8 @@
57
54
  </div>
58
55
  </div>
59
56
  <select id="prompt-select" hidden></select>
60
- <button id="browse-btn" data-tip="Browse all installed agents">⊞ Agents</button>
61
- <button id="routines-btn" data-tip="Routines run a workflow on a schedule or a live trigger">⏱ Routines</button>
57
+ <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
58
+ (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
62
59
  <span class="ctl-sep" aria-hidden="true"></span>
63
60
  <span class="run-state" id="run-state" role="status" aria-live="polite"></span>
64
61
  <button id="compile-btn" data-tip="Compile + approve → freeze the .lock">⎙ Compile</button>
@@ -265,7 +262,22 @@
265
262
  <span class="menu-label">Find on canvas</span>
266
263
  <span class="menu-kbd">Ctrl+F</span>
267
264
  </button>
265
+ <!-- Workspace view switch (was the header Canvas|Dashboard toggle, #149). One
266
+ contextual item: the label names the OTHER view; the dot lights when the
267
+ dashboard changed while on Canvas. Wired in panels.js (owns the view state). -->
268
+ <button class="menu-item" data-action="view-switch" role="menuitem">
269
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg></span>
270
+ <span class="menu-label" id="menu-view-label">Switch to Dashboard</span>
271
+ <span class="view-dot" id="dash-dot" hidden aria-label="Dashboard updated">●</span>
272
+ </button>
268
273
  <div class="menu-divider"></div>
274
+ <!-- Agents library (was the header ⊞ Agents button, #149). Heads the agent group
275
+ (Agents / Graft into agent / Bake into agent). Ctrl+G. -->
276
+ <button class="menu-item" data-action="agents" role="menuitem">
277
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span>
278
+ <span class="menu-label">Agents</span>
279
+ <span class="menu-kbd">Ctrl+G</span>
280
+ </button>
269
281
  <button class="menu-item" data-action="graft" role="menuitem">
270
282
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg></span>
271
283
  <span class="menu-label">Graft into agent</span>
@@ -283,6 +295,7 @@
283
295
  <button class="menu-item" data-action="routines" role="menuitem">
284
296
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg></span>
285
297
  <span class="menu-label">Routines</span>
298
+ <span class="menu-kbd">Ctrl+R</span>
286
299
  </button>
287
300
  <div class="menu-divider"></div>
288
301
  <button class="menu-item" data-action="report-issue" role="menuitem">
@@ -28,8 +28,9 @@
28
28
 
29
29
  const $canvasMain = document.getElementById('canvas-main');
30
30
  const $dashboard = document.getElementById('dashboard');
31
- const $viewToggle = document.getElementById('view-toggle');
32
- const $dashDot = document.getElementById('dash-dot');
31
+ const $dashDot = document.getElementById('dash-dot'); // the "updated" dot, now in the ≡ menu item
32
+ const $menuBtn = document.getElementById('menu-btn'); // hosts the dashboard-updated badge (#149)
33
+ const $menuViewLabel = document.getElementById('menu-view-label'); // the ≡ "Switch to Dashboard/Canvas" label
33
34
  const $extBadge = document.getElementById('ext-badge');
34
35
  const $extMenu = document.getElementById('ext-menu');
35
36
  const $extUndo = document.getElementById('ext-undo');
@@ -38,7 +39,7 @@
38
39
  const $centerName = document.getElementById('center-panel-name');
39
40
  const $centerRole = document.getElementById('center-panel-role');
40
41
  const $resetModal = document.getElementById('ext-reset-modal');
41
- if (!$dashboard || !$viewToggle) return; // markup absent — nothing to wire
42
+ if (!$dashboard) return; // markup absent — nothing to wire
42
43
 
43
44
  const LS_VIEW = 'floless:view';
44
45
  let view = 'canvas';
@@ -65,18 +66,23 @@
65
66
  const descriptorInvalid = () => !!(ext.validation && ext.validation.valid === false);
66
67
 
67
68
  // ── view toggle (Canvas | Dashboard) ─────────────────────────────────────────
69
+ // The "dashboard updated" signal: a dot on the ≡ menu's view-switch item AND a badge on
70
+ // the ≡ hamburger, so it's visible without opening the menu (#149 — the old toggle that
71
+ // hosted the dot is gone).
72
+ function setDashUpdated(on) {
73
+ if ($dashDot) $dashDot.hidden = !on;
74
+ if ($menuBtn) $menuBtn.classList.toggle('has-dash-update', on);
75
+ }
68
76
  function applyView() {
69
77
  const dash = view === 'dashboard';
70
78
  if ($canvasMain) $canvasMain.classList.toggle('view-dashboard', dash);
71
79
  $dashboard.hidden = !dash;
72
- $viewToggle.querySelectorAll('.view-btn').forEach((b) => {
73
- const on = b.dataset.view === view;
74
- b.classList.toggle('active', on);
75
- b.setAttribute('aria-pressed', on ? 'true' : 'false');
76
- });
80
+ // The Canvas|Dashboard toggle now lives in the ≡ menu as one contextual item (#149):
81
+ // its label names the OTHER view the destination of the switch.
82
+ if ($menuViewLabel) $menuViewLabel.textContent = dash ? 'Switch to Canvas' : 'Switch to Dashboard';
77
83
  if ($centerName) $centerName.textContent = dash ? 'Dashboard' : 'Canvas';
78
84
  if ($centerRole) $centerRole.textContent = dash ? 'your panels · composed by your terminal AI' : 'transparency layer · read-mostly';
79
- if (dash && $dashDot) $dashDot.hidden = true; // seen — clear the "updated" dot
85
+ if (dash) setDashUpdated(false); // seen — clear the "updated" signal
80
86
  }
81
87
  function setView(v) {
82
88
  view = v === 'dashboard' ? 'dashboard' : 'canvas';
@@ -84,7 +90,15 @@
84
90
  applyView();
85
91
  if (view === 'dashboard') renderDashboard();
86
92
  }
87
- $viewToggle.querySelectorAll('.view-btn').forEach((b) => { b.onclick = () => setView(b.dataset.view); });
93
+ function toggleView() { setView(view === 'dashboard' ? 'canvas' : 'dashboard'); }
94
+ // The ≡ menu's "Switch to Dashboard/Canvas" item drives the view (the header toggle was
95
+ // removed in #149). handleMenuAction lives in app.js and is already wrapped in aware.js
96
+ // for graft/bake; wrap once more here for view-switch and fall through for everything else.
97
+ const _prevHandleMenuAction = handleMenuAction;
98
+ handleMenuAction = function (action) {
99
+ if (action === 'view-switch') { toggleView(); return; }
100
+ _prevHandleMenuAction(action);
101
+ };
88
102
 
89
103
  // ── block renderers (all output escaped; placeholders over errors) ──────────
90
104
  const payloadFor = (source) => (typeof source === 'string' ? ext.data[source] : undefined);
@@ -685,7 +699,7 @@
685
699
  renderDashboard();
686
700
  // Content changed while the user is on Canvas → light the Dashboard dot
687
701
  // (never auto-switch — don't yank their context).
688
- if (opts.fromChange && view === 'canvas' && allPanels().length && $dashDot) $dashDot.hidden = false;
702
+ if (opts.fromChange && view === 'canvas' && allPanels().length) setDashUpdated(true);
689
703
  refreshPending();
690
704
  }
691
705