@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/floless-server.cjs +512 -310
- package/dist/skills/floless-app-bridge/SKILL.md +27 -1
- package/dist/skills/floless-app-rebake/SKILL.md +77 -0
- package/dist/skills/floless-app-routines/SKILL.md +1 -1
- package/dist/skills/floless-app-ui/SKILL.md +1 -1
- package/dist/skills/floless-app-workflows/SKILL.md +8 -2
- package/dist/skills/floless-app-workflows/references/exec-contract.md +10 -1
- package/dist/skills/floless-app-workflows/references/visual-inputs.md +94 -0
- package/dist/web/app.css +90 -5
- package/dist/web/app.js +51 -5
- package/dist/web/aware.js +330 -54
- package/dist/web/index.html +11 -1
- package/dist/web/panels.js +5 -3
- package/package.json +1 -1
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
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
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
|
|
947
|
-
if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num;
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
|
1345
|
-
const
|
|
1346
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 ──────────────────────────────────────────────────────────────────────
|