@floless/app 0.18.0 → 0.19.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
@@ -1096,6 +1096,11 @@
1096
1096
  /* A disabled action reads as inert — never a primary that looks clickable but isn't. */
1097
1097
  .modal-actions button:disabled { opacity: 0.5; cursor: default; }
1098
1098
  .modal-actions button.primary:disabled:hover { background: var(--accent); box-shadow: none; }
1099
+ /* Destructive confirm (#85): the commit button is neutral at rest and turns
1100
+ error-red only on hover, so an accidental Enter (focus sits on the accent Cancel)
1101
+ never deletes — matching the app's other destructive affordances. */
1102
+ .modal-actions button.danger { border-color: var(--border-strong); color: var(--text); font-weight: 600; }
1103
+ .modal-actions button.danger:hover { color: var(--err); border-color: var(--err); background: rgba(248, 113, 113, 0.1); box-shadow: none; }
1099
1104
  .modal.library {
1100
1105
  width: 720px;
1101
1106
  max-height: 82vh;
@@ -1585,6 +1590,20 @@
1585
1590
  .wf-option.selected .wf-option-name { color: var(--accent); }
1586
1591
  .wf-option-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1587
1592
  .wf-option-meta { font-family: var(--mono); font-size: 10px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
1593
+ /* Per-row lifecycle actions (#85) — hover/keyboard-revealed, mirroring the #68
1594
+ template chip's ✎/× treatment one level up (act on an installed workflow). */
1595
+ .wf-option-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
1596
+ .wf-act {
1597
+ display: inline-flex; align-items: center; justify-content: center;
1598
+ width: 20px; height: 20px; padding: 0; border: 1px solid transparent; border-radius: 3px;
1599
+ background: transparent; color: var(--text-dim); font: inherit; font-size: 12px; line-height: 1;
1600
+ cursor: pointer; opacity: 0; transition: opacity .12s ease, color .12s, background .12s, border-color .12s;
1601
+ }
1602
+ .wf-option:hover .wf-act, .wf-option.highlighted .wf-act { opacity: 0.45; }
1603
+ .wf-act:hover { opacity: 1; }
1604
+ .wf-act.act-edit:hover { color: var(--accent); background: var(--accent-soft); }
1605
+ .wf-act.act-dup:hover { color: var(--text); border-color: var(--border-strong); }
1606
+ .wf-act.act-del:hover { color: var(--err); background: rgba(248, 113, 113, 0.1); border-color: var(--err); }
1588
1607
  .wf-empty-search { color: var(--text-dim); font-style: italic; font-size: 12px; padding: 16px 10px; text-align: center; }
1589
1608
  .wf-empty-search[hidden] { display: none; }
1590
1609
 
package/dist/web/aware.js CHANGED
@@ -578,8 +578,17 @@
578
578
  html += `<div class="wf-group" role="group" aria-label="${escapeAttr(providerLabel(p))}"><div class="wf-group-header">${escapeHtml(providerLabel(p))}</div>`;
579
579
  for (const a of rows) {
580
580
  const meta = `${escapeHtml(String(a.nodes))} node${a.nodes === 1 ? '' : 's'} · ${escapeHtml(String(a.layout))}`;
581
- html += `<button type="button" class="wf-option" role="option" id="wf-opt-${escapeAttr(a.id)}" data-id="${escapeAttr(a.id)}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" aria-selected="false">`
582
- + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span></button>`;
581
+ // A div (not a button) carries role="option" so the action icons can be real
582
+ // <button>s without nesting interactive elements (#85). Selection stays driven by
583
+ // the delegated $wfList click + comboMove highlight (aria-activedescendant).
584
+ const aid = escapeAttr(a.id);
585
+ html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
586
+ + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
587
+ + `<span class="wf-option-actions">`
588
+ + `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
589
+ + `<button type="button" class="wf-act act-dup" data-wf-act="duplicate" data-id="${aid}" tabindex="-1" data-tip="Duplicate" aria-label="Duplicate ${aid}">⧉</button>`
590
+ + `<button type="button" class="wf-act act-del" data-wf-act="delete" data-id="${aid}" tabindex="-1" data-tip="Delete" aria-label="Delete ${aid}">×</button>`
591
+ + `</span></div>`;
583
592
  }
584
593
  html += '</div>';
585
594
  }
@@ -695,6 +704,15 @@
695
704
  }
696
705
  if ($wfList) {
697
706
  $wfList.addEventListener('click', (e) => {
707
+ // A row action (#85) takes precedence over selecting the workflow.
708
+ const act = e.target.closest('[data-wf-act]');
709
+ if (act) {
710
+ const id = act.dataset.id;
711
+ if (act.dataset.wfAct === 'rename') openRenameWorkflow(id);
712
+ else if (act.dataset.wfAct === 'duplicate') openDuplicateWorkflow(id);
713
+ else if (act.dataset.wfAct === 'delete') confirmDeleteWorkflow(id);
714
+ return;
715
+ }
698
716
  const o = e.target.closest('.wf-option');
699
717
  if (o) selectComboById(o.dataset.id);
700
718
  });
@@ -703,6 +721,112 @@
703
721
  if ($wfPopover && !$wfPopover.hidden && $wfCombo && !$wfCombo.contains(e.target)) closeCombo(false);
704
722
  });
705
723
 
724
+ // ── Workflow lifecycle actions: rename / duplicate / delete (#85) ─────────────
725
+ // The server owns the fs work + `aware app compile`/`uninstall` + the app-id cascade
726
+ // (visual inputs, bound routines). Here we drive the modal, migrate the CLIENT-side
727
+ // saved input VALUES (localStorage, keyed by app id), set LS_LAST_APP so the
728
+ // apps-changed SSE → loadApps() reopens the right workflow, and toast the outcome.
729
+ function migrateInputsLS(fromId, toId) {
730
+ try {
731
+ const v = localStorage.getItem(lsInputsKey(fromId));
732
+ if (v != null) localStorage.setItem(lsInputsKey(toId), v);
733
+ } catch { /* private mode / quota */ }
734
+ }
735
+ function dropClientAppState(id) {
736
+ try { localStorage.removeItem(lsInputsKey(id)); } catch { /* ignore */ }
737
+ appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
738
+ }
739
+
740
+ async function openRenameWorkflow(oldId) {
741
+ closeCombo(false);
742
+ const res = await formModal({
743
+ title: 'Rename workflow',
744
+ sub: 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.',
745
+ fields: [{ name: 'name', label: 'New name', type: 'text', value: oldId, placeholder: 'e.g. tekla-bom-by-phase' }],
746
+ okLabel: 'Rename',
747
+ });
748
+ if (!res) return;
749
+ const newId = String(res.name || '').trim();
750
+ if (!newId || newId === oldId) return;
751
+ showToast('Renaming…', 'ok');
752
+ try {
753
+ const r = await api(`/api/app/${encodeURIComponent(oldId)}/rename`, { method: 'PATCH', body: JSON.stringify({ name: newId }) });
754
+ // Carry saved input VALUES + any unsaved in-memory edits/dirty baseline to the new id
755
+ // so a rename never silently drops the user's inputs (#85 review). loadApp() only seeds
756
+ // inputs when the id is absent, so pre-setting them here preserves unsaved edits.
757
+ migrateInputsLS(oldId, r.id);
758
+ if (appInputValues.has(oldId)) appInputValues.set(r.id, appInputValues.get(oldId));
759
+ if (dirtyBaseline.has(oldId)) dirtyBaseline.set(r.id, dirtyBaseline.get(oldId));
760
+ const wasOpen = currentId === oldId;
761
+ dropClientAppState(oldId);
762
+ if (wasOpen) { try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } }
763
+ await loadApps();
764
+ showToast(r.compiled ? `Renamed to “${r.id}”` : `Renamed to “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
765
+ } catch (e) {
766
+ console.error(e); // an unexpected client throw (not a clean server message) stays debuggable
767
+ showToast(e.message || 'Rename failed', 'warn');
768
+ }
769
+ }
770
+
771
+ async function openDuplicateWorkflow(srcId) {
772
+ closeCombo(false);
773
+ const res = await formModal({
774
+ title: 'Duplicate workflow',
775
+ sub: 'Makes an independent copy you can edit and run separately — compiled and ready.',
776
+ fields: [{ name: 'name', label: 'New name', type: 'text', value: `${srcId}-copy`, placeholder: 'e.g. tekla-bom-by-phase-copy' }],
777
+ okLabel: 'Duplicate',
778
+ });
779
+ if (!res) return;
780
+ const newId = String(res.name || '').trim();
781
+ if (!newId) return;
782
+ showToast('Duplicating…', 'ok');
783
+ try {
784
+ const r = await api(`/api/app/${encodeURIComponent(srcId)}/duplicate`, { method: 'POST', body: JSON.stringify({ name: newId }) });
785
+ migrateInputsLS(srcId, r.id); // the copy starts with the source's saved inputs
786
+ try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } // open the copy
787
+ await loadApps();
788
+ showToast(r.compiled ? `Duplicated as “${r.id}”` : `Duplicated as “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
789
+ } catch (e) {
790
+ console.error(e);
791
+ showToast(e.message || 'Duplicate failed', 'warn');
792
+ }
793
+ }
794
+
795
+ async function confirmDeleteWorkflow(id) {
796
+ // Warn honestly if a routine (schedule OR trigger) is bound — the server cascade removes it.
797
+ let routineNote = '';
798
+ try {
799
+ const { routines } = await api('/api/routines');
800
+ const bound = (routines || []).filter((r) => r.workflow === id).length;
801
+ if (bound) routineNote = ` Its ${bound} bound routine${bound === 1 ? '' : 's'} will also be removed.`;
802
+ } catch {
803
+ // Couldn't read routines — never UNDER-warn before an irreversible delete: state the
804
+ // worst case so the user knows bound routines (if any) go too (the cascade still removes them).
805
+ routineNote = ' Any routines bound to it will also be removed.';
806
+ }
807
+ closeCombo(false);
808
+ const ok = await formModal({
809
+ title: 'Delete workflow?',
810
+ sub: `Permanently delete “${id}” — its source, lock and saved inputs.${routineNote} This can’t be undone.`,
811
+ fields: [],
812
+ okLabel: 'Delete',
813
+ danger: true,
814
+ });
815
+ if (!ok) return;
816
+ showToast('Deleting…', 'ok');
817
+ try {
818
+ await api(`/api/app/${encodeURIComponent(id)}`, { method: 'DELETE' });
819
+ const wasOpen = currentId === id;
820
+ dropClientAppState(id);
821
+ if (wasOpen) { currentId = null; try { localStorage.removeItem(LS_LAST_APP); } catch { /* ignore */ } }
822
+ await loadApps();
823
+ showToast(`Deleted “${id}”`, 'ok');
824
+ } catch (e) {
825
+ console.error(e);
826
+ showToast(e.message || 'Delete failed', 'err'); // a failed destructive delete reads as an error
827
+ }
828
+ }
829
+
706
830
  // ── app inputs + HTML Viewer ────────────────────────────────────────────────
707
831
 
708
832
  // The id of the report-viewer node — the terminal node a user double-clicks to
@@ -1349,7 +1473,7 @@
1349
1473
  // `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
1350
1474
  // { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
1351
1475
  const $formModal = document.getElementById('form-modal');
1352
- function formModal({ title, sub = '', fields, okLabel = 'Save' }) {
1476
+ function formModal({ title, sub = '', fields, okLabel = 'Save', danger = false }) {
1353
1477
  return new Promise((resolve) => {
1354
1478
  const $title = document.getElementById('form-modal-title');
1355
1479
  const $sub = document.getElementById('form-modal-sub');
@@ -1387,9 +1511,16 @@
1387
1511
  return `<div class="modal-field"><label for="${id}">${escapeHtml(f.label || f.name)}</label>${ctl}</div>`;
1388
1512
  }).join('');
1389
1513
  $ok.textContent = okLabel;
1514
+ // A destructive confirm flips the emphasis: the commit button is neutral→red
1515
+ // (.danger) and the SAFE Cancel becomes the accent primary + gets focus, so a
1516
+ // stray Enter never deletes (#85).
1517
+ $ok.classList.toggle('danger', !!danger);
1518
+ $ok.classList.toggle('primary', !danger);
1519
+ $cancel.classList.toggle('primary', !!danger);
1390
1520
  showModal($formModal);
1391
1521
  const first = $body.querySelector('input:not(.fm-file-input),textarea');
1392
1522
  if (first) { first.focus(); if (first.select) first.select(); }
1523
+ else if (danger) $cancel.focus();
1393
1524
  // ── images-field setup ──────────────────────────────────────────────────────
1394
1525
  const imageStates = new Map();
1395
1526
  $body.querySelectorAll('.fm-images').forEach((box) => {
@@ -1505,15 +1636,19 @@
1505
1636
  };
1506
1637
  const done = (result) => {
1507
1638
  hideModal($formModal);
1508
- $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $body.onkeydown = null; $body.onpaste = null;
1639
+ $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
1640
+ $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
1509
1641
  resolve(result);
1510
1642
  };
1511
1643
  $ok.onclick = () => done(collect());
1512
1644
  $cancel.onclick = () => done(null);
1513
1645
  onBackdropDismiss($formModal, () => done(null));
1514
- $body.onkeydown = (e) => {
1515
- if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
1516
- else if (e.key === 'Escape') { e.preventDefault(); done(null); }
1646
+ // Keydown at the MODAL level (not $body) so Esc cancels regardless of focus — e.g. a
1647
+ // destructive confirm where focus sits on Cancel, not in the (empty) body. Enter
1648
+ // commits only for a normal form (never a danger confirm; never inside a textarea).
1649
+ $formModal.onkeydown = (e) => {
1650
+ if (e.key === 'Escape') { e.preventDefault(); done(null); }
1651
+ else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
1517
1652
  };
1518
1653
  });
1519
1654
  }
@@ -1919,7 +2054,25 @@
1919
2054
  // The server derives the id from the file's `app:` field, installs it, and we select
1920
2055
  // it so the user can Compile (we never auto-compile — Run stays a deliberate act).
1921
2056
  const $importFile = document.getElementById('import-file');
1922
- function triggerImport() { if ($importFile) $importFile.click(); }
2057
+ // Prefer the File System Access API: its types[].description names the OS picker's filter
2058
+ // ("FloLess workflow"), whereas a plain <input accept=…> with several extensions renders as
2059
+ // the generic "Custom Files". Fall back to the hidden <input> where the API is absent
2060
+ // (Firefox/Safari). Called synchronously from the menu-click gesture (no await before the
2061
+ // picker call), so the required user-activation is preserved.
2062
+ async function triggerImport() {
2063
+ if (typeof window.showOpenFilePicker === 'function') {
2064
+ let handle;
2065
+ try {
2066
+ [handle] = await window.showOpenFilePicker({
2067
+ types: [{ description: 'FloLess workflow', accept: { 'text/plain': ['.flo', '.app', '.flow', '.aware'] } }],
2068
+ multiple: false,
2069
+ });
2070
+ } catch { return; } // the user dismissed the picker (AbortError) — nothing to import
2071
+ try { importFlo(await handle.getFile()); } catch { showToast('Could not read that file', 'err'); }
2072
+ return;
2073
+ }
2074
+ if ($importFile) $importFile.click();
2075
+ }
1923
2076
 
1924
2077
  async function importFlo(file) {
1925
2078
  if (!file) return;
@@ -3100,7 +3253,13 @@
3100
3253
  // The host AI (or anyone) edited this app's .flo/.lock in the terminal —
3101
3254
  // refresh so the canvas + gate reflect reality.
3102
3255
  clearTimeout(reloadTimer);
3103
- reloadTimer = setTimeout(() => loadApp(currentId).catch(reportErr), 250);
3256
+ reloadTimer = setTimeout(() => {
3257
+ // Skip if the open app was renamed/removed out from under us (#85) — the
3258
+ // apps-changed handler already refreshes the picker, and reloading a gone id
3259
+ // would 404. dropClientAppState() removes it from `apps` before this fires.
3260
+ if (!currentId || !apps.has(currentId)) return;
3261
+ loadApp(currentId).catch(reportErr);
3262
+ }, 250);
3104
3263
  } else if (m.type === 'seat-taken') {
3105
3264
  // This session's seat was claimed by another device (newest-login-wins).
3106
3265
  showToast('Signed in on another device — sign in here to continue.', 'err');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.18.0",
3
+ "version": "0.19.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": {