@floless/app 0.16.2 → 0.18.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/aware.js CHANGED
@@ -284,6 +284,7 @@
284
284
  for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
285
285
  return {
286
286
  _mode: n.mode,
287
+ _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
287
288
  icon: iconFor(n.agent),
288
289
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
289
290
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
@@ -780,13 +781,27 @@
780
781
  // every markSpecialNodes — which openInputsDialog calls right after a change.
781
782
  function addNodeInputs(card) {
782
783
  const vals = currentInputs();
783
- const keys = Object.keys(vals);
784
+ const app = currentId && apps.get(currentId);
785
+ const declared = app && Array.isArray(app.inputs) ? app.inputs : [];
786
+ const byName = new Map(declared.map((i) => [i.name, i]));
787
+ const isVisual = (inp) => !!inp && (inp.widget === 'file' || inp.type === 'image' || inp.type === 'file');
788
+ const baseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
789
+ // Show every SET value, plus any declared visual input (so its "not set" /
790
+ // attach affordance is visible on the node before the first pick).
791
+ const keys = [...new Set([...Object.keys(vals), ...declared.filter(isVisual).map((i) => i.name)])];
784
792
  if (!keys.length) return;
785
793
  const wrap = document.createElement('div');
786
794
  wrap.className = 'node-inputs';
787
- wrap.innerHTML = keys
788
- .map((k) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`)
789
- .join('');
795
+ wrap.innerHTML = keys.map((k) => {
796
+ const inp = byName.get(k);
797
+ if (isVisual(inp)) {
798
+ const v = String(vals[k] || '');
799
+ if (!v) return `<span class="ni-pair ni-pair-file ni-pair-file-empty"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val ni-not-set">not set</span></span>`;
800
+ const glyph = /\.pdf$/i.test(v) ? 'pdf' : 'img';
801
+ return `<span class="ni-pair ni-pair-file"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-file-glyph">${glyph}</span><span class="ni-val ni-file-name" title="${escapeAttr(v)}">${escapeHtml(baseName(v))}</span></span>`;
802
+ }
803
+ return `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`;
804
+ }).join('');
790
805
  card.appendChild(wrap);
791
806
  }
792
807
 
@@ -797,18 +812,57 @@
797
812
  function markSpecialNodes() {
798
813
  const rid = reportNodeId();
799
814
  const iid = inputNodeId();
800
- document.querySelectorAll('.agent-card').forEach((card) => {
815
+ // B3: a baked-visual app (read-strategy: bake) has no runtime input node, so the
816
+ // "Re-read & re-bake ▸" affordance lands on the entry (first) node card.
817
+ const app = currentId && apps.get(currentId);
818
+ const rebakeInput = app && app.rebakeInput ? app.rebakeInput : null;
819
+ const cards = document.querySelectorAll('.agent-card');
820
+ const firstId = cards.length ? cards[0].dataset.agentId : null;
821
+ cards.forEach((card) => {
801
822
  const id = card.dataset.agentId;
802
823
  const isReport = !!rid && id === rid;
803
824
  const isInput = !!iid && id === iid;
825
+ const isRebake = !!rebakeInput && id === firstId;
804
826
  card.classList.toggle('report-node', isReport);
805
827
  card.classList.toggle('input-node', isInput);
828
+ card.classList.toggle('rebake-node', isRebake);
806
829
  // clear anything we injected last render, so re-renders never stack
807
830
  card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
808
831
  if (isInput) addNodeInputs(card); // current values, above the button
809
832
  if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
810
833
  if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
834
+ if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
835
+ });
836
+ }
837
+
838
+ // B3 "swap re-bakes": pick a new source drawing for a baked app and queue a
839
+ // re-bake request. Thin-UI — the host AI (via the floless-app-rebake skill)
840
+ // does the actual re-read + re-bake at compose time; the browser only records
841
+ // intent + the new image. Re-uses the SP1 file field + the request relay.
842
+ async function openRebakeDialog() {
843
+ const app = currentId && apps.get(currentId);
844
+ if (!app || !app.rebakeInput) return;
845
+ const name = app.rebakeInput;
846
+ const res = await formModal({
847
+ title: `Re-read & re-bake · ${app.displayName}`,
848
+ sub: 'Swap the source drawing. Your terminal AI will re-read it, re-bake the values into config, then ask you to approve.',
849
+ fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['png', 'jpg', 'webp'] }],
850
+ okLabel: 'Queue re-bake',
811
851
  });
852
+ if (!res) return;
853
+ const dataUrl = res[name];
854
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
855
+ try {
856
+ const r = await fetch('/api/rebake', {
857
+ method: 'POST',
858
+ headers: { 'content-type': 'application/json' },
859
+ body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
860
+ });
861
+ const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
862
+ if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
863
+ showToast('Queued — your terminal AI will re-read & re-bake, then ask you to approve.', 'ok');
864
+ appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, re-bake the config literals, and ask you to approve the new lock.`);
865
+ } catch { showToast('could not queue re-bake', 'err'); }
812
866
  }
813
867
 
814
868
  // Per-app declared-input values, set via the input node's double-click dialog.
@@ -932,25 +986,54 @@
932
986
  const app = currentId && apps.get(currentId);
933
987
  if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return;
934
988
  const cur = currentInputs();
935
- const fields = app.inputs.map((inp) => ({
936
- name: inp.name,
937
- label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
938
- type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
939
- value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
940
- }));
989
+ const isVisual = (inp) => inp.widget === 'file' || inp.type === 'image' || inp.type === 'file';
990
+ const visualNames = new Set(app.inputs.filter(isVisual).map((i) => i.name));
991
+ const fields = app.inputs.map((inp) => isVisual(inp)
992
+ ? {
993
+ name: inp.name,
994
+ label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
995
+ type: 'file',
996
+ accept: inp.accept,
997
+ value: cur[inp.name] != null ? cur[inp.name] : '',
998
+ }
999
+ : {
1000
+ name: inp.name,
1001
+ label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
1002
+ type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
1003
+ value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
1004
+ });
941
1005
  const res = await formModal({ title: `Inputs · ${app.displayName}`, sub: 'Set the values this run uses, then ▶ Run workflow.', fields, okLabel: 'Set inputs' });
942
1006
  if (!res) return;
943
1007
  const store = {};
944
- app.inputs.forEach((inp) => {
1008
+ for (const inp of app.inputs) {
1009
+ if (isVisual(inp)) {
1010
+ const v = res[inp.name];
1011
+ if (typeof v === 'string' && v.startsWith('data:')) {
1012
+ // Fresh pick/paste → persist it to the store; the input VALUE is the path.
1013
+ try {
1014
+ const r = await fetch('/api/visual-input', {
1015
+ method: 'POST',
1016
+ headers: { 'content-type': 'application/json' },
1017
+ body: JSON.stringify({ appId: app.id, name: inp.name, dataUrl: v }),
1018
+ });
1019
+ const up = await r.json().catch(() => ({ ok: false, error: `upload failed (${r.status})` }));
1020
+ if (!up || !up.ok) { showToast((up && up.error) || 'could not store the file', 'err'); return; }
1021
+ store[inp.name] = up.path;
1022
+ } catch { showToast('could not upload the file', 'err'); return; }
1023
+ } else if (typeof v === 'string' && v) {
1024
+ store[inp.name] = v; // unchanged existing path
1025
+ } // cleared/empty → omit (no value)
1026
+ continue;
1027
+ }
945
1028
  let v = String(res[inp.name] ?? '').trim();
946
- if (v === '') { if (inp.default != null) v = String(inp.default); else return; }
947
- if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; return; } }
1029
+ if (v === '') { if (inp.default != null) v = String(inp.default); else continue; }
1030
+ if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; continue; } }
948
1031
  store[inp.name] = v;
949
- });
1032
+ }
950
1033
  appInputValues.set(currentId, store);
951
1034
  markSpecialNodes();
952
1035
  refreshDirtyIndicator(); // values changed → reflect unsaved state in the header/menu
953
- const badge = Object.entries(store).map(([k, v]) => `${k}=${v}`).join(' · ');
1036
+ const badge = Object.entries(store).map(([k, v]) => `${k}=${visualNames.has(k) ? String(v).split(/[\\/]/).pop() : v}`).join(' · ');
954
1037
  appendNarration(`Inputs set — <strong>${escapeHtml(badge)}</strong>. Run with <strong>▶ Run workflow</strong>.`);
955
1038
  showToast('Inputs set: ' + badge, 'ok');
956
1039
  }
@@ -1257,7 +1340,7 @@
1257
1340
  $save.onclick = () => done('save');
1258
1341
  $dont.onclick = () => done('discard');
1259
1342
  $cancel.onclick = () => done('cancel');
1260
- $confirmModal.onclick = (e) => { if (e.target === $confirmModal) done('cancel'); };
1343
+ onBackdropDismiss($confirmModal, () => done('cancel'));
1261
1344
  document.addEventListener('keydown', onKey, true);
1262
1345
  });
1263
1346
  }
@@ -1286,6 +1369,16 @@
1286
1369
  <div class="fm-drop" role="button" tabindex="0" aria-label="Paste a screenshot or click to attach">Paste a screenshot (Ctrl+V) or click to add<input type="file" accept="image/*" multiple class="fm-file-input" tabindex="-1" aria-hidden="true"></div>
1287
1370
  <div class="fm-thumbs" hidden></div>
1288
1371
  </div>`;
1372
+ } else if (f.type === 'file') {
1373
+ // Single image/PDF Visual Input. Reuses the .fm-drop visual language; the
1374
+ // hidden file input is a SIBLING of the drop (not a child) so render() can
1375
+ // freely rewrite the drop's innerHTML between empty/has-file without losing it.
1376
+ const accepts = (f.accept && f.accept.length) ? f.accept : ['png', 'jpg', 'webp', 'pdf'];
1377
+ const acceptAttr = accepts.map((e) => '.' + String(e).replace(/^\./, '')).join(',');
1378
+ ctl = `<div class="fm-file" data-fm-file-box="${escapeAttr(f.name)}" data-accept-hint="${escapeAttr(accepts.join(', ').toUpperCase())}" data-init-value="${escapeAttr(val)}">
1379
+ <div class="fm-drop" role="button" tabindex="0" aria-label="Attach an image or PDF"></div>
1380
+ <input type="file" accept="${escapeAttr(acceptAttr)}" class="fm-file-input" tabindex="-1" aria-hidden="true">
1381
+ </div>`;
1289
1382
  } else {
1290
1383
  ctl = f.multiline
1291
1384
  ? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
@@ -1333,32 +1426,81 @@
1333
1426
  fileInput.value = '';
1334
1427
  };
1335
1428
  });
1336
- // ── paste support: paste an image into the modal → first images field ──────
1429
+ // ── file-field setup (single image/PDF Visual Input) ───────────────────────
1430
+ // st.mode: 'empty' | 'path' (a previously-set on-disk path, passed through
1431
+ // unchanged) | 'dataurl' (a fresh pick/paste the caller uploads to the store).
1432
+ const fileStates = new Map();
1433
+ const fmBaseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
1434
+ const fmExtOf = (p) => { const m = /\.([a-z0-9]+)$/i.exec(String(p || '')); return m ? m[1].toLowerCase() : ''; };
1435
+ $body.querySelectorAll('.fm-file').forEach((box) => {
1436
+ const name = box.dataset.fmFileBox;
1437
+ const hint = box.dataset.acceptHint || 'PNG, JPG, PDF';
1438
+ const drop = box.querySelector('.fm-drop');
1439
+ const fileInput = box.querySelector('.fm-file-input');
1440
+ const init = box.dataset.initValue || '';
1441
+ const st = init
1442
+ ? { mode: 'path', value: init, name: fmBaseName(init), ext: fmExtOf(init) }
1443
+ : { mode: 'empty', value: '', name: '', ext: '' };
1444
+ const render = () => {
1445
+ const has = st.mode !== 'empty';
1446
+ drop.classList.toggle('has-file', has);
1447
+ if (!has) {
1448
+ drop.innerHTML = `Paste (Ctrl+V) or click to attach<span class="fm-drop-hint">${escapeHtml(hint)}</span>`;
1449
+ return;
1450
+ }
1451
+ const glyph = st.ext === 'pdf' ? 'pdf' : 'img';
1452
+ drop.innerHTML = `<span class="fm-file-glyph">${glyph}</span><span class="fm-file-name" title="${escapeAttr(st.value)}">${escapeHtml(st.name)}</span><span class="fm-file-actions"><button type="button" class="fm-file-replace">Replace</button><button type="button" class="fm-file-clear">Clear</button></span>`;
1453
+ drop.querySelector('.fm-file-replace').onclick = (e) => { e.stopPropagation(); fileInput.click(); };
1454
+ drop.querySelector('.fm-file-clear').onclick = (e) => { e.stopPropagation(); st.mode = 'empty'; st.value = ''; st.name = ''; st.ext = ''; render(); };
1455
+ };
1456
+ const setFile = (file) => {
1457
+ if (!file) return;
1458
+ const ext = fmExtOf(file.name) || (file.type === 'application/pdf' ? 'pdf' : '');
1459
+ const reader = new FileReader();
1460
+ reader.onload = () => { st.mode = 'dataurl'; st.value = reader.result; st.name = file.name || ('pasted.' + (ext || 'png')); st.ext = ext; render(); };
1461
+ reader.onerror = () => showToast('could not read the file', 'err');
1462
+ reader.readAsDataURL(file);
1463
+ };
1464
+ drop.onclick = () => { if (st.mode === 'empty') fileInput.click(); };
1465
+ drop.onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && st.mode === 'empty') { e.preventDefault(); fileInput.click(); } };
1466
+ fileInput.onchange = () => { const f0 = (fileInput.files || [])[0]; if (f0) setFile(f0); fileInput.value = ''; };
1467
+ fileStates.set(name, { st, setFile });
1468
+ render();
1469
+ });
1470
+ // ── paste support: an image into the modal → the images field, else the file field ──
1337
1471
  $body.onpaste = (e) => {
1338
- const box = $body.querySelector('.fm-images');
1339
- if (!box) return;
1340
1472
  const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
1341
1473
  const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
1342
- if (!imgs.length) return;
1474
+ const box = $body.querySelector('.fm-images');
1475
+ if (box) {
1476
+ if (!imgs.length) return;
1477
+ e.preventDefault();
1478
+ const entry = imageStates.get(box.dataset.fmImages);
1479
+ if (!entry) return;
1480
+ const { state, renderThumbs } = entry;
1481
+ const room = Math.max(0, 8 - state.length);
1482
+ if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
1483
+ imgs.slice(0, room).forEach((it) => {
1484
+ const file = it.getAsFile();
1485
+ if (!file) return;
1486
+ const reader = new FileReader();
1487
+ reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
1488
+ reader.readAsDataURL(file);
1489
+ });
1490
+ return;
1491
+ }
1492
+ const fileBox = $body.querySelector('.fm-file');
1493
+ if (!fileBox || !imgs.length) return;
1343
1494
  e.preventDefault();
1344
- const name = box.dataset.fmImages;
1345
- const entry = imageStates.get(name);
1346
- if (!entry) return;
1347
- const { state, renderThumbs } = entry;
1348
- const room = Math.max(0, 8 - state.length);
1349
- if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
1350
- imgs.slice(0, room).forEach((it) => {
1351
- const file = it.getAsFile();
1352
- if (!file) return;
1353
- const reader = new FileReader();
1354
- reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
1355
- reader.readAsDataURL(file);
1356
- });
1495
+ const entry = fileStates.get(fileBox.dataset.fmFileBox);
1496
+ const file = imgs[0].getAsFile();
1497
+ if (entry && file) entry.setFile(file);
1357
1498
  };
1358
1499
  const collect = () => {
1359
1500
  const out = {};
1360
1501
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
1361
1502
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
1503
+ fileStates.forEach((entry, name) => { out[name] = entry.st.mode === 'empty' ? '' : entry.st.value; });
1362
1504
  return out;
1363
1505
  };
1364
1506
  const done = (result) => {
@@ -1368,7 +1510,7 @@
1368
1510
  };
1369
1511
  $ok.onclick = () => done(collect());
1370
1512
  $cancel.onclick = () => done(null);
1371
- $formModal.onclick = (e) => { if (e.target === $formModal) done(null); };
1513
+ onBackdropDismiss($formModal, () => done(null));
1372
1514
  $body.onkeydown = (e) => {
1373
1515
  if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
1374
1516
  else if (e.key === 'Escape') { e.preventDefault(); done(null); }
@@ -1393,7 +1535,7 @@
1393
1535
  // BEHIND the report modal's backdrop, so a session shown in the Viewer needs its
1394
1536
  // own reachable Stop. Visibility is driven by syncRunControls (foregroundTrigger).
1395
1537
  if ($reportStop) $reportStop.onclick = () => stopRun();
1396
- $reportModal.addEventListener('click', (e) => { if (e.target === $reportModal) hideModal($reportModal); });
1538
+ onBackdropDismiss($reportModal, () => hideModal($reportModal));
1397
1539
  // The Stop button is rebuilt into the overlay each run — delegate so one
1398
1540
  // listener survives every innerHTML swap.
1399
1541
  $reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
@@ -1532,7 +1674,7 @@
1532
1674
 
1533
1675
  document.getElementById('bake-cancel').onclick = () => hideModal($bakeModal);
1534
1676
  document.getElementById('bake-confirm').onclick = () => runBake();
1535
- $bakeModal.onclick = (e) => { if (e.target === $bakeModal && !document.getElementById('bake-confirm').disabled) hideModal($bakeModal); };
1677
+ onBackdropDismiss($bakeModal, () => hideModal($bakeModal), () => !document.getElementById('bake-confirm').disabled);
1536
1678
 
1537
1679
  // ── Graft into agent ────────────────────────────────────────────────────────
1538
1680
  // Build an agent from a foreign tool (DLL / C# source / NuGet / OpenAPI / …).
@@ -1755,7 +1897,7 @@
1755
1897
  $graftBack.onclick = () => { if (graftState.onBack) graftState.onBack(); };
1756
1898
  $graftCancel.onclick = () => hideModal($graftModal);
1757
1899
  $graftPrimary.onclick = () => { if (graftState.onPrimary) graftState.onPrimary(); };
1758
- $graftModal.onclick = (e) => { if (e.target === $graftModal && !$graftCancel.disabled) hideModal($graftModal); };
1900
+ onBackdropDismiss($graftModal, () => hideModal($graftModal), () => !$graftCancel.disabled);
1759
1901
 
1760
1902
  // Secondary entry: "⊕ Graft new agent" inside the Agents Library modal.
1761
1903
  const $libGraft = document.getElementById('lib-graft');
@@ -1768,9 +1910,59 @@
1768
1910
  handleMenuAction = function (action) {
1769
1911
  if (action === 'graft') { openGraftModal(); return; }
1770
1912
  if (action === 'bake') { openBakeModal(); return; }
1913
+ if (action === 'import') { triggerImport(); return; }
1771
1914
  _handleMenuAction(action);
1772
1915
  };
1773
1916
 
1917
+ // ── Import a shared workflow (#66) ────────────────────────────────────────────
1918
+ // A teammate's .flo, via the ≡ menu's "Import workflow…" OR dropped onto the canvas.
1919
+ // The server derives the id from the file's `app:` field, installs it, and we select
1920
+ // it so the user can Compile (we never auto-compile — Run stays a deliberate act).
1921
+ const $importFile = document.getElementById('import-file');
1922
+ function triggerImport() { if ($importFile) $importFile.click(); }
1923
+
1924
+ async function importFlo(file) {
1925
+ if (!file) return;
1926
+ let content;
1927
+ try { content = await file.text(); } catch { showToast('Could not read that file', 'err'); return; }
1928
+ try {
1929
+ const { id } = await api('/api/import', { method: 'POST', body: JSON.stringify({ filename: file.name, content }) });
1930
+ await loadApps();
1931
+ $promptSel.value = id;
1932
+ $promptSel.dispatchEvent(new Event('change', { bubbles: true })); // → loadApp(id), arms Compile
1933
+ showToast(`Imported "${id}" — Compile to run`, 'ok');
1934
+ } catch (e) {
1935
+ const body = e && e.body;
1936
+ const msg = (body && body.error) || (e && e.message) || 'Import failed';
1937
+ showToast(msg, body && body.code === 'exists' ? 'warn' : 'err');
1938
+ }
1939
+ }
1940
+
1941
+ if ($importFile) {
1942
+ $importFile.onchange = () => {
1943
+ const f = $importFile.files && $importFile.files[0];
1944
+ $importFile.value = ''; // reset so re-picking the same filename still fires change
1945
+ importFlo(f);
1946
+ };
1947
+ }
1948
+
1949
+ // OS-file drop on the canvas. Distinguished from in-app node-card HTML5 drags (which
1950
+ // carry no "Files" type) so dragging a card still drops onto the Templates bar (#71).
1951
+ const $canvasMain = document.getElementById('canvas-main');
1952
+ const $dropTarget = document.getElementById('canvas-drop-target');
1953
+ const isFileDrag = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
1954
+ if ($canvasMain && $dropTarget) {
1955
+ $canvasMain.addEventListener('dragenter', (e) => { if (isFileDrag(e)) { e.preventDefault(); $dropTarget.classList.add('active'); } });
1956
+ $canvasMain.addEventListener('dragover', (e) => { if (isFileDrag(e)) e.preventDefault(); }); // required to allow the drop
1957
+ $canvasMain.addEventListener('dragleave', (e) => { if (!$canvasMain.contains(e.relatedTarget)) $dropTarget.classList.remove('active'); });
1958
+ $canvasMain.addEventListener('drop', (e) => {
1959
+ if (!isFileDrag(e)) return; // let node-card drops fall through to the Templates bar
1960
+ e.preventDefault();
1961
+ $dropTarget.classList.remove('active');
1962
+ importFlo(e.dataTransfer.files && e.dataTransfer.files[0]);
1963
+ });
1964
+ }
1965
+
1774
1966
  // ── Release notes: shared state + popover ─────────────────────────────────────
1775
1967
  // The channel-correct public site base (e.g. https://floless.io), captured from
1776
1968
  // /api/health in the health poll. The changelog deep-link is omitted entirely when
@@ -2843,6 +3035,9 @@
2843
3035
  state.hasRun = true;
2844
3036
  } else if (m.type === 'templates-changed') {
2845
3037
  loadTemplates().catch(() => {});
3038
+ } else if (m.type === 'apps-changed') {
3039
+ // A workflow was imported (here or in another tab) → refresh the picker (#66).
3040
+ loadApps().catch(() => {});
2846
3041
  } else if (m.type === 'baked' && m.id === currentId) {
2847
3042
  // Baked (here or in another tab) → refresh so the menu item flips to "Re-bake".
2848
3043
  loadApp(currentId).catch(() => {});
@@ -2958,8 +3153,9 @@
2958
3153
 
2959
3154
  openAddFavModal = function openAddFavModalTpl(nodeId) {
2960
3155
  state.pendingFavAgentId = nodeId;
3156
+ state.editingTemplateId = null; // CREATE mode
2961
3157
  const a = AGENTS[nodeId];
2962
- $addFavSub.textContent = `Save "${nodeId}" as a reusable template — usable in any project.`;
3158
+ setFavModalChrome('Save as Template', `Save "${nodeId}" as a reusable template — usable in any project.`, '★ Save');
2963
3159
  $favName.value = a ? a.title : nodeId;
2964
3160
  $favCat.value = '';
2965
3161
  renderCategorySuggestions();
@@ -2967,7 +3163,47 @@
2967
3163
  setTimeout(() => $favCat.focus(), 50);
2968
3164
  };
2969
3165
 
3166
+ // Swap the shared Add-Template modal's title / subtitle / save-button label so the
3167
+ // same modal serves both CREATE (from a node) and EDIT (rename/recategorize) (#68).
3168
+ function setFavModalChrome(title, sub, saveLabel) {
3169
+ const $t = document.getElementById('add-fav-title');
3170
+ const $s = document.getElementById('fav-save');
3171
+ if ($t) $t.textContent = title;
3172
+ $addFavSub.textContent = sub;
3173
+ if ($s) $s.textContent = saveLabel;
3174
+ }
3175
+
3176
+ // Open the modal in EDIT mode for an existing template. Only name + category are
3177
+ // editable; the captured node is immutable (re-star a node to change its logic) (#68).
3178
+ function openEditTemplate(id) {
3179
+ const tpl = state.favorites.find((t) => t.id === id);
3180
+ if (!tpl) return;
3181
+ state.editingTemplateId = id;
3182
+ state.pendingFavAgentId = null;
3183
+ setFavModalChrome('Edit Template', 'Rename or recategorize this template.', 'Save changes');
3184
+ $favName.value = tpl.name;
3185
+ $favCat.value = tpl.category;
3186
+ renderCategorySuggestions();
3187
+ $addFavModal.classList.add('show');
3188
+ setTimeout(() => { $favName.focus(); $favName.select(); }, 50);
3189
+ }
3190
+
2970
3191
  commitFav = async function commitFavTpl() {
3192
+ // EDIT mode (#68): PATCH the existing template's name/category; node is untouched.
3193
+ if (state.editingTemplateId) {
3194
+ const id = state.editingTemplateId;
3195
+ const name = ($favName.value || '').trim();
3196
+ const category = ($favCat.value || '').trim() || 'Uncategorized';
3197
+ if (!name) { showToast('Name required', 'warn'); return; }
3198
+ try {
3199
+ await api(`/api/templates/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, category }) });
3200
+ $addFavModal.classList.remove('show');
3201
+ state.editingTemplateId = null;
3202
+ await loadTemplates();
3203
+ showToast(`Updated template "${name}"`, 'ok');
3204
+ } catch (e) { reportErr(e); }
3205
+ return;
3206
+ }
2971
3207
  const nodeId = state.pendingFavAgentId;
2972
3208
  if (!nodeId) return;
2973
3209
  const name = ($favName.value || '').trim() || nodeId;
@@ -3002,14 +3238,16 @@
3002
3238
  if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
3003
3239
  $favBarEmpty.style.display = 'none';
3004
3240
  $favChipRow.innerHTML = tpls.map((t) => `
3005
- <div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Use in this workflow · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
3241
+ <div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Click to use · ✎ rename · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
3006
3242
  <span class="cat">${escapeHtml(t.category)}</span>
3007
3243
  <span class="name">${escapeHtml(t.name)}</span>
3008
- <span class="del" data-tip="Delete template">×</span>
3244
+ <span class="edit" data-tip="Rename / recategorize" aria-label="Edit template">✎</span>
3245
+ <span class="del" data-tip="Delete template" aria-label="Delete template">×</span>
3009
3246
  </div>`).join('');
3010
3247
  $favChipRow.querySelectorAll('.fav-chip').forEach((chip) => {
3011
3248
  const id = chip.dataset.tpl;
3012
- chip.onclick = (e) => { if (e.target.closest('.del')) return; useTemplate(id); };
3249
+ chip.onclick = (e) => { if (e.target.closest('.del') || e.target.closest('.edit')) return; useTemplate(id); };
3250
+ chip.querySelector('.edit').onclick = (e) => { e.stopPropagation(); openEditTemplate(id); };
3013
3251
  chip.querySelector('.del').onclick = (e) => { e.stopPropagation(); deleteTemplate(id); };
3014
3252
  });
3015
3253
  };
@@ -3036,9 +3274,42 @@
3036
3274
  const scope = req.panelId ? ` (panel "${req.panelId}")` : '';
3037
3275
  return `Customize my floless.app dashboard${scope}: ${req.instruction}. Edit ~/.floless/ui/extensions.json per the floless-app-ui skill, then check it with \`aware agent invoke ui validate\`.`;
3038
3276
  }
3277
+ if (req.type === 'rebake') {
3278
+ const where = req.inputName ? ` (input "${req.inputName}")` : '';
3279
+ const snaps = req.snapshots && req.snapshots.length
3280
+ ? `\nNew drawing${req.snapshots.length > 1 ? 's' : ''} (read for the re-extraction): ${req.snapshots.join(', ')}`
3281
+ : '';
3282
+ return `In floless app "${req.appId}", re-read & re-bake${where} per the floless-app-rebake skill: ${req.instruction}${snaps}`;
3283
+ }
3039
3284
  return '';
3040
3285
  }
3041
3286
 
3287
+ // Which product skill applies each request type (named in the copied marker so the
3288
+ // terminal AI picks up the right one).
3289
+ const REQUEST_SKILL = {
3290
+ 'use-template': 'floless-app-workflows',
3291
+ tweak: 'floless-app-workflows',
3292
+ 'ui-customize': 'floless-app-ui',
3293
+ rebake: 'floless-app-rebake',
3294
+ };
3295
+
3296
+ // The COPIED form of a request: instructionFor() prefixed with a self-identifying
3297
+ // marker so a PASTED request is unmistakable to the terminal AI — it's a queued FloLess
3298
+ // Dashboard request to APPLY (via the named skill + the authoritative /api/requests),
3299
+ // not a literal instruction to run verbatim (#73). The modal preview keeps the plain
3300
+ // instructionFor() text; only the clipboard carries the marker.
3301
+ function markedInstruction(req) {
3302
+ const body = instructionFor(req);
3303
+ if (!body) return '';
3304
+ const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
3305
+ const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
3306
+ const marker =
3307
+ `[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
3308
+ `Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
3309
+ `apply that request, then DELETE ${base}/api/requests/${req.id}. Don't run the line below verbatim.`;
3310
+ return `${marker}\n${body}`;
3311
+ }
3312
+
3042
3313
  async function copyToClipboard(text) {
3043
3314
  try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
3044
3315
  }
@@ -3047,7 +3318,7 @@
3047
3318
  if (!currentId) { showToast('open a workflow first', 'warn'); return; }
3048
3319
  try {
3049
3320
  const { request } = await api('/api/use-template', { method: 'POST', body: JSON.stringify({ appId: currentId, templateId }) });
3050
- const line = instructionFor(request);
3321
+ const line = markedInstruction(request);
3051
3322
  const copied = await copyToClipboard(line);
3052
3323
  appendNarration(`Queued template <strong>${escapeHtml(request.template.name)}</strong> for workflow <code>${escapeHtml(currentId)}</code> — the UI can’t edit the workflow itself, so your terminal AI picks this up and applies it. ${copied ? 'Instruction copied to your clipboard — paste it in.' : 'Open the requests chip (bottom-right) to copy it.'}`);
3053
3324
  showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
@@ -3072,7 +3343,7 @@
3072
3343
  try {
3073
3344
  const snaps = Array.isArray(res.snapshots) ? res.snapshots.map((s) => ({ name: s.name, dataUrl: s.dataUrl })) : [];
3074
3345
  const { request } = await api('/api/tweak', { method: 'POST', body: JSON.stringify({ appId: currentId, nodeId: node, instruction, snapshots: snaps }) });
3075
- const copied = await copyToClipboard(instructionFor(request));
3346
+ const copied = await copyToClipboard(markedInstruction(request));
3076
3347
  appendNarration(`Tweak queued for <code>${escapeHtml(node)}</code> — your terminal AI can pull it (floless skill) ${copied ? 'or paste the copied instruction' : ''}.`);
3077
3348
  const toastMsg = snaps.length
3078
3349
  ? `Tweak + ${snaps.length} snapshot(s) queued${copied ? ' + copied' : ''}`
@@ -3114,9 +3385,13 @@
3114
3385
  return;
3115
3386
  }
3116
3387
  $list.innerHTML = pendingRequests.map((r) => {
3117
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : 'tweak';
3118
- const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' ? 'req-type req-type-tweak' : 'req-type';
3119
- const target = r.type === 'tweak' && r.nodeId ? ` · node <code>${escapeHtml(r.nodeId)}</code>` : '';
3388
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : 'tweak';
3389
+ const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
3390
+ const target = r.type === 'tweak' && r.nodeId
3391
+ ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
3392
+ : r.type === 'rebake' && r.inputName
3393
+ ? ` · input <code>${escapeHtml(r.inputName)}</code>`
3394
+ : '';
3120
3395
  const when = r.createdAt ? new Date(r.createdAt) : null;
3121
3396
  const time = when && !isNaN(when) ? `<span class="req-time">${escapeHtml(nowStamp(when))}</span>` : '';
3122
3397
  return `
@@ -3136,7 +3411,7 @@
3136
3411
  b.onclick = async () => {
3137
3412
  const r = pendingRequests.find((x) => x.id === b.dataset.id);
3138
3413
  if (!r) return;
3139
- const copied = await copyToClipboard(instructionFor(r));
3414
+ const copied = await copyToClipboard(markedInstruction(r));
3140
3415
  showToast(copied ? 'Copied — paste it to your terminal AI' : 'copy failed', copied ? 'ok' : 'err');
3141
3416
  };
3142
3417
  });
@@ -3822,7 +4097,7 @@
3822
4097
  const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
3823
4098
  $confirm.onclick = () => done(true);
3824
4099
  $cancel.onclick = () => done(false);
3825
- $m.onclick = (e) => { if (e.target === $m) done(false); };
4100
+ onBackdropDismiss($m, () => done(false));
3826
4101
  document.addEventListener('keydown', onKey, true);
3827
4102
  });
3828
4103
  }
@@ -4014,11 +4289,11 @@
4014
4289
  // Wiring (the modal elements are static in index.html, present when this runs).
4015
4290
  document.getElementById('routines-btn').onclick = () => openRoutines();
4016
4291
  document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
4017
- $routinesModal.onclick = (e) => { if (e.target === $routinesModal) hideModal($routinesModal); };
4292
+ onBackdropDismiss($routinesModal, () => hideModal($routinesModal));
4018
4293
  document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
4019
4294
  document.getElementById('rtn-edit-cancel').onclick = () => hideModal($routineEditModal);
4020
4295
  document.getElementById('rtn-edit-save').onclick = () => saveRoutine();
4021
- $routineEditModal.onclick = (e) => { if (e.target === $routineEditModal) hideModal($routineEditModal); };
4296
+ onBackdropDismiss($routineEditModal, () => hideModal($routineEditModal));
4022
4297
  document.getElementById('rtn-kind').onchange = (e) => applySchedKind(e.target.value);
4023
4298
  document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => { b.onclick = () => setRoutineMode(b.dataset.mode); });
4024
4299
  document.getElementById('rtn-workflow').onchange = (e) => { if (!editingRoutineId) loadRoutineInputs(e.target.value, null); };
@@ -4206,7 +4481,7 @@
4206
4481
  $riDesc.addEventListener('input', riSyncSend);
4207
4482
  $riSend.onclick = () => riSubmit();
4208
4483
  $riCancel.onclick = () => riClose();
4209
- $riModal.onclick = (e) => { if (e.target === $riModal) riClose(); };
4484
+ onBackdropDismiss($riModal, () => riClose());
4210
4485
  $riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
4211
4486
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
4212
4487
 
@@ -4249,13 +4524,13 @@
4249
4524
  const $reqModal = document.getElementById('requests-modal');
4250
4525
  const $reqClose = document.getElementById('requests-close');
4251
4526
  if ($reqClose) $reqClose.onclick = () => $reqModal.classList.remove('show');
4252
- if ($reqModal) $reqModal.onclick = (e) => { if (e.target === $reqModal) $reqModal.classList.remove('show'); };
4527
+ if ($reqModal) onBackdropDismiss($reqModal, () => $reqModal.classList.remove('show'));
4253
4528
  const $reqClear = document.getElementById('requests-clear');
4254
4529
  if ($reqClear) $reqClear.onclick = () => clearAllRequests();
4255
4530
  const $reqCopy = document.getElementById('requests-copy');
4256
4531
  if ($reqCopy) $reqCopy.onclick = async () => {
4257
4532
  if (!pendingRequests.length) return;
4258
- const text = pendingRequests.map(instructionFor).filter(Boolean).join('\n\n');
4533
+ const text = pendingRequests.map(markedInstruction).filter(Boolean).join('\n\n');
4259
4534
  const copied = await copyToClipboard(text);
4260
4535
  showToast(copied ? `Copied ${pendingRequests.length} request(s) — paste to your terminal AI` : 'copy failed', copied ? 'ok' : 'err');
4261
4536
  };
@@ -4408,6 +4683,7 @@
4408
4683
  loadRequests,
4409
4684
  copyToClipboard,
4410
4685
  instructionFor,
4686
+ markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
4411
4687
  };
4412
4688
 
4413
4689
  // ── boot ──────────────────────────────────────────────────────────────────────