@floless/app 0.31.1 → 0.31.3

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.
@@ -52835,7 +52835,7 @@ function appVersion() {
52835
52835
  return resolveVersion({
52836
52836
  isSea: isSea2(),
52837
52837
  sqVersionXml: readSqVersionXml(),
52838
- define: true ? "0.31.1" : void 0,
52838
+ define: true ? "0.31.3" : void 0,
52839
52839
  pkgVersion: readPkgVersion()
52840
52840
  });
52841
52841
  }
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
52845
52845
  return "dev";
52846
52846
  }
52847
52847
  function appChannel() {
52848
- return resolveChannel({ isSea: isSea2(), define: true ? "0.31.1" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.31.3" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
package/dist/web/app.css CHANGED
@@ -566,6 +566,7 @@
566
566
  font-size: 12px;
567
567
  background: var(--accent-soft);
568
568
  flex-shrink: 0;
569
+ cursor: help;
569
570
  }
570
571
  .agent-card .fav-btn {
571
572
  position: absolute;
@@ -1124,6 +1125,10 @@
1124
1125
  never deletes — matching the app's other destructive affordances. */
1125
1126
  .modal-actions button.danger { border-color: var(--border-strong); color: var(--text); font-weight: 600; }
1126
1127
  .modal-actions button.danger:hover { color: var(--err); border-color: var(--err); background: rgba(248, 113, 113, 0.1); box-shadow: none; }
1128
+ /* #125: form modal with many fields must not overflow the viewport — cap + scroll
1129
+ the body between the pinned title and actions, mirroring .modal.routines. */
1130
+ #form-modal .modal { max-height: 86vh; display: flex; flex-direction: column; }
1131
+ #form-modal-body { overflow-y: auto; flex: 1 1 auto; min-height: 0; }
1127
1132
  .modal.library {
1128
1133
  width: 720px;
1129
1134
  max-height: 82vh;
package/dist/web/app.js CHANGED
@@ -352,7 +352,7 @@ function cardEl(id, ports) {
352
352
  <div class="kind">${a.kind}</div>
353
353
  <div class="ver">${a.version}</div>
354
354
  </div>
355
- <div class="icon">${a.icon}</div>
355
+ <div class="icon"${a.iconTip ? ` data-tip="${escapeAttr(a.iconTip)}"` : ''}>${a.icon}</div>
356
356
  </div>
357
357
  <div class="title">${a.title}</div>
358
358
  <div class="subtitle">${a.subtitle}</div>
package/dist/web/aware.js CHANGED
@@ -94,10 +94,28 @@
94
94
  }
95
95
 
96
96
  const AGENT_ICONS = {
97
+ tekla: '<line x1="4" y1="5" x2="20" y2="5"/><line x1="12" y1="5" x2="12" y2="20"/>',
98
+ file: '<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/>',
99
+ excel: '<rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="2" y1="12" x2="22" y2="12"/>',
100
+ revit: '<rect x="3" y="4" width="18" height="17" rx="1"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="21" x2="12" y2="12"/>',
101
+ slack: '<line x1="4" y1="10" x2="20" y2="10"/><line x1="4" y1="14" x2="20" y2="14"/><line x1="10" y1="4" x2="8" y2="20"/><line x1="16" y1="4" x2="14" y2="20"/>',
102
+ 'trimble-connect': '<path d="M17.5 10a4.5 4.5 0 00-9 0A4 4 0 009 18h6a4 4 0 002.5-7z"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 12 15 15"/>',
103
+ 'microsoft-365': '<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>',
104
+ email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 7l10 7 10-7"/>',
105
+ http: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
106
+ };
107
+ const AGENT_NAMES = {
108
+ tekla: 'Tekla', file: 'File', excel: 'Excel', revit: 'Revit', slack: 'Slack',
109
+ 'trimble-connect': 'Trimble Connect', 'microsoft-365': 'Microsoft 365', email: 'Email', http: 'HTTP',
110
+ };
111
+ const _svgWrap = (inner) => `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
112
+ const iconFor = (agent) => _svgWrap(AGENT_ICONS[agent] || '<path d="M12 2l8 10-8 10L4 12z"/>');
113
+ const iconTipFor = (agent) => AGENT_NAMES[agent] || (agent ? agent.replace(/-/g, ' ') : '');
114
+ const AGENT_ICONS_TEXT = {
97
115
  tekla: '⊞', file: '🗀', excel: '◵', revit: '◰', slack: '✦',
98
116
  'trimble-connect': '☁', 'microsoft-365': '✉', email: '✉', http: '⇄',
99
117
  };
100
- const iconFor = (agent) => AGENT_ICONS[agent] || '◈';
118
+ const iconTextFor = (agent) => AGENT_ICONS_TEXT[agent] || '◈';
101
119
 
102
120
  // The demo's renderChat staggers narration via setTimeout; its bootstrap call
103
121
  // (fired before this script runs) would otherwise drip stale demo lines into
@@ -153,6 +171,57 @@
153
171
  .join('')}</table>`;
154
172
  }
155
173
 
174
+ // The Description tab is for non-coders — it must read as plain English about what a
175
+ // node does, never a wall of code/markup. Short scalar inputs (phase: 1, version:
176
+ // '2025.0') are the real knobs and show verbatim; but a baked blob (HTML markup, a
177
+ // large JSON payload pre-built at compile time, e.g. a steel overlay's `args.html`)
178
+ // is NOT plain English — dumping it raw is the "rubbish" this view must never show.
179
+ // So anything oversized collapses to a dimmed one-line summary ("baked HTML · 210.0 KB").
180
+ const DESC_BLOB_CHARS = 500;
181
+ function summarizeInputValue(v) {
182
+ const isStr = typeof v === 'string';
183
+ const raw = isStr ? v : v == null ? '' : JSON.stringify(v);
184
+ // Summarize on size OR on any markup/code shape — a non-coder must never see raw
185
+ // markup, however short. (The slice bounds the scan to 500 chars; the regex is
186
+ // already linear — a single [^>]* with a disjoint `>` terminator — so no ReDoS.)
187
+ const markup = /<\/?[a-z!][^>]*>/i.test(raw.slice(0, 500));
188
+ if (raw.length <= DESC_BLOB_CHARS && !markup) return { text: raw, blob: false };
189
+ const size = raw.length >= 1024 ? (raw.length / 1024).toFixed(1) + ' KB' : raw.length + ' chars';
190
+ const kind = markup ? 'HTML' : isStr ? 'content' : 'JSON';
191
+ return { text: `baked ${kind} · ${size}`, blob: true };
192
+ }
193
+
194
+ // Exec nodes receive their inputs wrapped in an `args` object (an AWARE implementation
195
+ // detail of how exec passes data in). Flatten that one level so a non-coder sees the
196
+ // real knobs (phase, html…) directly instead of an `args: {…}` row. `code` is the
197
+ // implementation, never an input, so it's dropped; a flattened member that collides
198
+ // with a real top-level input yields to the top-level value.
199
+ function flattenInputs(inputs) {
200
+ const out = Object.create(null); // null-proto: keys like `constructor`/`__proto__` stay plain data
201
+ for (const [k, v] of Object.entries(inputs || {})) {
202
+ if (k === 'code') continue;
203
+ if (k === 'args' && v && typeof v === 'object' && !Array.isArray(v)) {
204
+ for (const [ak, av] of Object.entries(v)) if (ak !== 'code' && !Object.hasOwn(out, ak)) out[ak] = av;
205
+ } else {
206
+ out[k] = v;
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ // Description-tab Inputs block: flattened, plain-English, baked blobs summarized and
213
+ // dimmed (meta, not data). Empty inputs → no block at all.
214
+ function descInputs(inputs) {
215
+ const rows = Object.entries(flattenInputs(inputs));
216
+ if (!rows.length) return '';
217
+ const body = rows.map(([k, v]) => {
218
+ const s = summarizeInputValue(v);
219
+ const cell = s.blob ? `<span class="dim-note">${escapeHtml(s.text)}</span>` : escapeHtml(s.text);
220
+ return `<tr><th>${escapeHtml(k)}</th><td>${cell}</td></tr>`;
221
+ }).join('');
222
+ return `<p><strong>Inputs</strong></p><table class="kv">${body}</table>`;
223
+ }
224
+
156
225
  // Plain-text summary of one declared command input (for the Skill tab's inputs
157
226
  // table — kvTable escapes it, so this stays text, no markup): "type (enum vals)
158
227
  // · default X — description".
@@ -289,19 +358,19 @@
289
358
  // Description tab: a plain-English explanation + a small input table that
290
359
  // EXCLUDES the code (code belongs to the Code tab, never dumped here).
291
360
  const plainDesc = execSource ? leadingComment(execSource) : '';
292
- const inputsNoCode = {};
293
- for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
361
+ const blurbText = plainDesc && (s => s.length > 80 ? s.slice(0, 79) + '…' : s)(plainDesc.replace(/\.\s+.*/, '.').trim());
294
362
  return {
295
363
  _mode: n.mode,
296
364
  _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
297
365
  _frozen: !!n.frozen, // source node carries a `frozen:` block → card badge + Unfreeze menu
298
366
  icon: iconFor(n.agent),
367
+ iconTip: iconTipFor(n.agent),
299
368
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
300
369
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
301
370
  title: escapeHtml(n.id),
302
371
  subtitle: `${agentLabel}${cmd ? '/' + cmd : ''} · ${mode}`,
303
- blurb: n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
304
- description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${Object.keys(inputsNoCode).length ? `<p><strong>Inputs</strong></p>${kvTable(inputsNoCode)}` : ''}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
372
+ blurb: blurbText ? escapeHtml(blurbText) : n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
373
+ description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${descInputs(inputs)}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
305
374
  // Rich agent/command detail when the manifest is installed (n.skill); else
306
375
  // the lock-pointer fallback below (exec/agent-less nodes, uninstalled agents).
307
376
  skill: skillBody(
@@ -1624,7 +1693,7 @@
1624
1693
  card.setAttribute('role', 'status');
1625
1694
 
1626
1695
  const status = mkEl('div', 'cred-fail-status');
1627
- const icon = mkEl('span', 'cred-fail-icon', iconFor(cred.id));
1696
+ const icon = mkEl('span', 'cred-fail-icon', iconTextFor(cred.id));
1628
1697
  icon.setAttribute('aria-hidden', 'true');
1629
1698
  status.append(icon, document.createTextNode('SESSION EXPIRED'));
1630
1699
 
@@ -1774,6 +1843,7 @@
1774
1843
  const first = $body.querySelector('input:not(.fm-file-input),textarea');
1775
1844
  if (first) { first.focus(); if (first.select) first.select(); }
1776
1845
  else if (danger) $cancel.focus();
1846
+ else $ok.focus();
1777
1847
  // ── images-field setup ──────────────────────────────────────────────────────
1778
1848
  const imageStates = new Map();
1779
1849
  $body.querySelectorAll('.fm-images').forEach((box) => {
@@ -2563,6 +2633,7 @@
2563
2633
  // popover → Update now → POST /api/update/apply, which downloads + relaunches into
2564
2634
  // the new version. Hidden in dev/npm (supported:false).
2565
2635
  const $appUpdate = document.getElementById('app-update');
2636
+ let _appUpdateRecoveryTimer = null; // cleared before each new apply attempt
2566
2637
  // The apply body, extracted so the popover's "Update now" can drive it with the
2567
2638
  // polled target version (no textContent scrape). Preserves the npm-vs-desktop
2568
2639
  // branch verbatim: npm copies the command; desktop confirms → relaunch.
@@ -2578,18 +2649,32 @@
2578
2649
  }
2579
2650
  // desktop (Velopack): one-click download + relaunch
2580
2651
  const v = version || '';
2581
- if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
2652
+ 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' });
2653
+ if (!go) return;
2582
2654
  // Record the pending version so the new build's first paint can reveal the
2583
2655
  // what's-new panel (survives the relaunch via localStorage; cleared once shown).
2584
2656
  try { localStorage.setItem('floless.whatsNew.pending', v); } catch { /* private mode */ }
2657
+ if (_appUpdateRecoveryTimer) { clearTimeout(_appUpdateRecoveryTimer); _appUpdateRecoveryTimer = null; }
2585
2658
  $appUpdate.disabled = true;
2586
2659
  $appUpdate.textContent = '↑ Updating…';
2587
2660
  try {
2588
- const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
2661
+ const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(5 * 60 * 1000) });
2589
2662
  const d = await r.json().catch(() => ({}));
2590
2663
  if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
2591
2664
  // Success: the server is exiting + Update.exe relaunches the new build. The health
2592
2665
  // poll flips to offline (R1 overlay), then the new version reconnects on its own.
2666
+ // Recovery guard: if the relaunch never happens and this page stays alive (e.g. a UAC
2667
+ // prompt was declined after the server already responded), re-enable the pill after 90 s
2668
+ // so the user can retry — the page won't go offline in that case.
2669
+ _appUpdateRecoveryTimer = setTimeout(() => {
2670
+ _appUpdateRecoveryTimer = null;
2671
+ if ($appUpdate && $appUpdate.disabled) {
2672
+ $appUpdate.disabled = false;
2673
+ $appUpdate.textContent = '↑ Relaunch failed — retry';
2674
+ try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
2675
+ refreshUpdate(); // re-poll so the pill state reflects reality, same as catch path
2676
+ }
2677
+ }, 90_000);
2593
2678
  } catch (e) {
2594
2679
  $appUpdate.disabled = false;
2595
2680
  try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
@@ -2667,7 +2752,8 @@
2667
2752
  async function applyAwareUpdate(version) {
2668
2753
  if (!$awareUpdate) return;
2669
2754
  const v = version || '';
2670
- if (!window.confirm('Upgrade AWARE runtime to v' + v + '? This reinstalls the npm package in place the app stays open and the version restamps automatically.')) return;
2755
+ 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' });
2756
+ if (!go) return;
2671
2757
  $awareUpdate.disabled = true;
2672
2758
  $awareUpdate.textContent = '↑ Upgrading…';
2673
2759
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.31.1",
3
+ "version": "0.31.3",
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": {