@floless/app 0.17.0 → 0.18.1

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.
@@ -50920,6 +50920,10 @@ function appDir(id) {
50920
50920
  if (!(0, import_node_fs3.existsSync)(dir)) throw new AppNotFoundError(id);
50921
50921
  return dir;
50922
50922
  }
50923
+ function appExists(id) {
50924
+ if (id.includes("/") || id.includes("\\") || id.includes("..")) return false;
50925
+ return (0, import_node_fs3.existsSync)((0, import_node_path2.join)(APPS_DIR, id));
50926
+ }
50923
50927
  function firstWithExt(dir, ext) {
50924
50928
  const hit = (0, import_node_fs3.readdirSync)(dir).find((f) => f.toLowerCase().endsWith(ext));
50925
50929
  return hit ? (0, import_node_path2.join)(dir, hit) : null;
@@ -52679,7 +52683,7 @@ function appVersion() {
52679
52683
  return resolveVersion({
52680
52684
  isSea: isSea2(),
52681
52685
  sqVersionXml: readSqVersionXml(),
52682
- define: true ? "0.17.0" : void 0,
52686
+ define: true ? "0.18.1" : void 0,
52683
52687
  pkgVersion: readPkgVersion()
52684
52688
  });
52685
52689
  }
@@ -52689,7 +52693,7 @@ function resolveChannel(s) {
52689
52693
  return "dev";
52690
52694
  }
52691
52695
  function appChannel() {
52692
- return resolveChannel({ isSea: isSea2(), define: true ? "0.17.0" : void 0 });
52696
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.18.1" : void 0 });
52693
52697
  }
52694
52698
 
52695
52699
  // oauth-presets.ts
@@ -52786,6 +52790,40 @@ function bakeFloSource(source, inputs) {
52786
52790
  return doc.toString();
52787
52791
  }
52788
52792
 
52793
+ // app-import.ts
52794
+ var APP_ID2 = /^[a-z0-9][a-z0-9._-]*$/i;
52795
+ var SOURCE_EXT2 = /\.(flo|app|flow|aware)$/i;
52796
+ var ImportError = class extends Error {
52797
+ code;
52798
+ constructor(message, code) {
52799
+ super(message);
52800
+ this.name = "ImportError";
52801
+ this.code = code;
52802
+ }
52803
+ };
52804
+ function assertImportable(content, filename) {
52805
+ if (filename && !SOURCE_EXT2.test(filename)) {
52806
+ throw new ImportError("that file isn't a .flo workflow \u2014 drop a .flo file to import", "not-flo");
52807
+ }
52808
+ if (!content || !content.trim()) {
52809
+ throw new ImportError("that file is empty", "invalid");
52810
+ }
52811
+ if (!/^nodes:/m.test(content)) {
52812
+ throw new ImportError("that file doesn't look like a valid workflow \u2014 it may be truncated", "invalid");
52813
+ }
52814
+ }
52815
+ function deriveAppId(content) {
52816
+ const m = content.match(/^app:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m);
52817
+ if (!m) {
52818
+ throw new ImportError("that file doesn't look like a workflow \u2014 no top-level 'app:' name", "invalid");
52819
+ }
52820
+ const id = (m[1] ?? "").trim();
52821
+ if (!APP_ID2.test(id)) {
52822
+ throw new ImportError(`"${id}" isn't a valid workflow id`, "invalid");
52823
+ }
52824
+ return id;
52825
+ }
52826
+
52789
52827
  // report-badge.ts
52790
52828
  var BADGE_MARKER = "fla-credit";
52791
52829
  var DEFAULT_URL = "https://floless.io/made-with-floless?utm_source=report_badge";
@@ -53076,6 +53114,17 @@ function deleteTemplate(id) {
53076
53114
  function getTemplate(id) {
53077
53115
  return listTemplates().find((t) => t.id === id) ?? null;
53078
53116
  }
53117
+ function updateTemplate(id, patch) {
53118
+ const list = listTemplates();
53119
+ const cur = list.find((t) => t.id === id);
53120
+ if (!cur) return null;
53121
+ const name = patch.name?.trim();
53122
+ const category = patch.category?.trim();
53123
+ cur.name = name || cur.name;
53124
+ cur.category = category || cur.category;
53125
+ writeTemplates(list);
53126
+ return cur;
53127
+ }
53079
53128
  function addRequest(req, decoded = []) {
53080
53129
  ensureRoot();
53081
53130
  if (!(0, import_node_fs12.existsSync)(REQUESTS_DIR)) (0, import_node_fs12.mkdirSync)(REQUESTS_DIR, { recursive: true });
@@ -57320,6 +57369,39 @@ async function startServer() {
57320
57369
  broadcast({ type: "compiled", id: id ?? null, lockPath: result.lockPath });
57321
57370
  return { ok: true, result };
57322
57371
  });
57372
+ app.post("/api/import", async (req, reply) => {
57373
+ const { filename, content } = req.body ?? {};
57374
+ if (typeof content !== "string") return reply.status(400).send({ ok: false, error: "no file content", code: "invalid" });
57375
+ let id;
57376
+ try {
57377
+ assertImportable(content, filename);
57378
+ id = deriveAppId(content);
57379
+ } catch (err) {
57380
+ if (err instanceof ImportError) return reply.status(400).send({ ok: false, error: err.message, code: err.code });
57381
+ throw err;
57382
+ }
57383
+ if (appExists(id)) {
57384
+ return reply.status(409).send({ ok: false, error: `a workflow named "${id}" is already installed \u2014 remove it first, or rename the file's app: id`, code: "exists", id });
57385
+ }
57386
+ const stageRoot = (0, import_node_fs22.mkdtempSync)((0, import_node_path21.join)((0, import_node_os15.tmpdir)(), "floless-import-"));
57387
+ try {
57388
+ const stageDir = (0, import_node_path21.join)(stageRoot, id);
57389
+ (0, import_node_fs22.mkdirSync)(stageDir);
57390
+ (0, import_node_fs22.writeFileSync)((0, import_node_path21.join)(stageDir, `${id}.flo`), content);
57391
+ await aware.install(stageDir);
57392
+ } catch (err) {
57393
+ try {
57394
+ await aware.uninstall(id);
57395
+ } catch {
57396
+ }
57397
+ const msg = err instanceof AwareError ? err.message : String(err?.message ?? err);
57398
+ return reply.status(502).send({ ok: false, error: `import failed: ${msg}` });
57399
+ } finally {
57400
+ (0, import_node_fs22.rmSync)(stageRoot, { recursive: true, force: true });
57401
+ }
57402
+ broadcast({ type: "apps-changed", id });
57403
+ return { ok: true, id };
57404
+ });
57323
57405
  app.post("/api/bake", async (req, reply) => {
57324
57406
  const { id } = req.body ?? {};
57325
57407
  if (!id) return reply.status(400).send({ ok: false, error: "id required" });
@@ -57629,6 +57711,19 @@ async function startServer() {
57629
57711
  return { ok: true, template: tpl };
57630
57712
  }
57631
57713
  );
57714
+ app.patch(
57715
+ "/api/templates/:id",
57716
+ async (req, reply) => {
57717
+ const { name, category } = req.body ?? {};
57718
+ if ((name == null || !name.trim()) && (category == null || !category.trim())) {
57719
+ return reply.status(400).send({ ok: false, error: "name or category required" });
57720
+ }
57721
+ const tpl = updateTemplate(req.params.id, { name, category });
57722
+ if (!tpl) return reply.status(404).send({ ok: false, error: "template not found" });
57723
+ broadcast({ type: "templates-changed" });
57724
+ return { ok: true, template: tpl };
57725
+ }
57726
+ );
57632
57727
  app.delete("/api/templates/:id", async (req, reply) => {
57633
57728
  if (!deleteTemplate(req.params.id)) return reply.status(404).send({ ok: false, error: "template not found" });
57634
57729
  broadcast({ type: "templates-changed" });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: floless-app-bridge
3
- description: This skill should be used when the user is driving the floless.app web UI (the local prompt-native front door at http://127.0.0.1:4317) and wants the terminal AI to act on what they did there — picking up queued Tweak requests, "use this template" requests, or the context of a node they selected/edited on the canvas. Use it when the user says things like "apply the tweak I just queued", "I clicked Tweak on the bom node — make that change", "pick up my floless request", "what's pending in floless", or after they double-click/Tweak a node in the UI. It reads the floless.app local API, applies the change to the app's .flo, recompiles, and clears the request.
3
+ description: This skill should be used when the user is driving the floless.app web UI (the local prompt-native front door at http://127.0.0.1:4317) and wants the terminal AI to act on what they did there — picking up queued Tweak requests, "use this template" requests, or the context of a node they selected/edited on the canvas. Use it when the user says things like "apply the tweak I just queued", "I clicked Tweak on the bom node — make that change", "pick up my floless request", "what's pending in floless", or after they double-click/Tweak a node in the UI. ALSO use it the moment the user pastes a message that begins with a `[floless-request type=… id=…]` marker — that is a request copied from the FloLess Dashboard, NOT a literal instruction to run; resolve it via the API. It reads the floless.app local API, applies the change to the app's .flo, recompiles, and clears the request.
4
4
  metadata:
5
5
  version: 0.1.0
6
6
  ---
@@ -13,6 +13,32 @@ and you **pull** them here, do the real work (edit the `.flo`, recompile), and c
13
13
  skill is that pull side. Pair it with the **`floless-app-workflows`** skill, which owns
14
14
  the actual `.flo` authoring / install → compile → run loop.
15
15
 
16
+ ## Receiving a *pasted* request (the `[floless-request]` marker)
17
+
18
+ The user can either ask you to **pull** pending requests (below) **or paste one straight into
19
+ this chat**. Every request the Dashboard copies to the clipboard is prefixed with a
20
+ self-identifying marker line:
21
+
22
+ ```
23
+ [floless-request type=ui-customize id=4a1f…] — queued from the FloLess Dashboard. Apply it with your floless-app-ui skill: fetch the authoritative spec from GET http://127.0.0.1:<port>/api/requests, apply that request, then DELETE …/api/requests/<id>. Don't run the line below verbatim.
24
+ <a human-readable summary of the change>
25
+ ```
26
+
27
+ When a message starts with `[floless-request …]`, treat it as a **queued Dashboard request, not
28
+ a literal instruction**:
29
+
30
+ 1. **Resolve it from the API, not the pasted text.** `GET /api/requests` and find the entry whose
31
+ `id` matches the marker — that JSON is authoritative; the pasted line is only a summary.
32
+ 2. **Apply it with the skill the marker names** (`ui-customize` → `floless-app-ui`;
33
+ `tweak` / `use-template` → `floless-app-workflows`; `rebake` → `floless-app-rebake`). Use the
34
+ **port from the marker** if it isn't 4317.
35
+ 3. **`DELETE /api/requests/<id>`** when done.
36
+
37
+ Do **not** run the pasted line as a shell command, and do **not** report a "missing file/path" as
38
+ a bug — a path like `~/.floless/ui/extensions.json` is *created by the skill's own flow*, so its
39
+ absence is expected, not an error. (This is the #73 fix: a pasted request was being misread as a
40
+ direct instruction and a path-not-found bug was filed.)
41
+
16
42
  ## The local API (http://127.0.0.1:4317)
17
43
 
18
44
  The floless.app server runs locally on a **fixed port 4317** (override: `$PORT`). Confirm it's up
@@ -22,6 +22,11 @@ The floless.app UI surfaces a **"Re-read & re-bake ▸"** button on a baked-visu
22
22
  node. Clicking it (after attaching a new drawing) records a **`rebake` request** that you pick
23
23
  up here.
24
24
 
25
+ > **Pasted a request?** If the user pastes a message beginning with a
26
+ > `[floless-request type=rebake id=…]` marker, that's a request **copied from the FloLess
27
+ > Dashboard**, not a literal instruction. Resolve it via `GET /api/requests` (match the id) —
28
+ > don't run the pasted line verbatim — then follow the loop below and `DELETE` it when done.
29
+
25
30
  ## The loop
26
31
 
27
32
  1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
@@ -7,7 +7,7 @@ metadata:
7
7
 
8
8
  # Setting up floless.app routines (automatic .flo runs)
9
9
 
10
- A **routine** runs an installed AWARE workflow (`.flo` app) automatically — the hands-off
10
+ A **routine** runs an installed FloLess workflow (`.flo` app) automatically — the hands-off
11
11
  equivalent of clicking ▶ Run workflow in floless.app. There are two **kinds**, both authored
12
12
  through the same **`/api/routines`** REST surface:
13
13
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: floless-app-ui
3
- description: This skill should be used when the user wants to customize the floless.app Dashboard — add, change, rearrange, or remove custom panels (stat cards, data tables, report buttons, run buttons) — or when picking up queued 'ui-customize' requests from the floless.app Customize box. Use it when the user says things like "add a panel showing last night's BOM run", "pin a run button to my dashboard", "apply my dashboard change", "customize my floless dashboard", "pick up my floless UI request", or after they type into the Dashboard's Customize box. It edits ~/.floless/ui/extensions.json (the declarative UI descriptor), validates with the AWARE ui agent, and the app re-renders live.
3
+ description: This skill should be used when the user wants to customize the floless.app Dashboard — add, change, rearrange, or remove custom panels (stat cards, data tables, report buttons, run buttons) — or when picking up queued 'ui-customize' requests from the floless.app Customize box. Use it when the user says things like "add a panel showing last night's BOM run", "pin a run button to my dashboard", "apply my dashboard change", "customize my floless dashboard", "pick up my floless UI request", after they type into the Dashboard's Customize box, OR when they paste a message beginning with a `[floless-request type=ui-customize id=…]` marker (a request copied from the Dashboard — resolve it via /api/requests, don't run it verbatim). It edits ~/.floless/ui/extensions.json (the declarative UI descriptor), validates with the AWARE ui agent, and the app re-renders live.
4
4
  metadata:
5
5
  version: 0.1.0
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: floless-app-workflows
3
- description: This skill should be used when building, modifying, or reasoning about AWARE .flo workflows for floless.app — especially reusable apps that take inputs (e.g. a phase number) and run across different Tekla models, exec nodes that run Roslyn C# against the live model, the in-app HTML Viewer, the Code tab, or "Debug in VS". Covers the exec contract, the install→validate→compile→run loop, app inputs templating, and the server seams (aware-adapter, app-reader, index).
3
+ description: This skill should be used when building, modifying, or reasoning about FloLess .flo workflows for floless.app — especially reusable apps that take inputs (e.g. a phase number) and run across different Tekla models, exec nodes that run Roslyn C# against the live model, the in-app HTML Viewer, the Code tab, or "Debug in VS". Covers the exec contract, the install→validate→compile→run loop, app inputs templating, and the server seams (aware-adapter, app-reader, index).
4
4
  metadata:
5
5
  version: 0.1.0
6
6
  ---
@@ -14,10 +14,16 @@ Every capability is either `aware <verb>` (shelled through one adapter) or readi
14
14
  state off disk. The browser is just the window. Never make the UI compose workflows or call
15
15
  an LLM — it renders AWARE state, triggers `aware` verbs, and relays user intent.
16
16
 
17
- The deliverable floless.app produces is a **reusable AWARE `.flo` app**: one that declares
17
+ The deliverable floless.app produces is a **reusable FloLess `.flo` app** (which runs on AWARE): one that declares
18
18
  `inputs:` with defaults and runs unchanged across different models. The canonical example
19
19
  lives at `demos/tekla-bom-by-phase/` — study it before building a new one.
20
20
 
21
+ > **Pasted a request?** If the user pastes a message beginning with a
22
+ > `[floless-request type=tweak|use-template id=…]` marker, that's a request **copied from the
23
+ > FloLess Dashboard**, not a literal instruction. Resolve it via the API (`GET /api/requests`,
24
+ > match the id) and apply it per the **`floless-app-bridge`** skill's "Receiving a pasted
25
+ > request" section — then `DELETE /api/requests/<id>`. Don't run the pasted line verbatim.
26
+
21
27
  ## The reusable-app shape
22
28
 
23
29
  An app becomes reusable by declaring a top-level `inputs:` block and templating those inputs
package/dist/web/app.css CHANGED
@@ -347,6 +347,21 @@
347
347
  }
348
348
  /* While middle-mouse panning, force the grab cursor over the whole canvas. */
349
349
  .canvas.panning, .canvas.panning * { cursor: grabbing !important; }
350
+ /* OS-file drop target for importing a shared .flo (#66). Shown ONLY while a real file
351
+ is dragged over the canvas — distinguished from in-app node-card drags (which carry
352
+ no "Files" type) by the JS that toggles .active. Existing tokens only. */
353
+ .canvas-drop-target {
354
+ position: absolute; inset: 8px; z-index: 20;
355
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
356
+ background: color-mix(in srgb, var(--bg) 80%, transparent);
357
+ border: 2px dashed var(--accent-dim); border-radius: 6px;
358
+ pointer-events: none; opacity: 0; transition: opacity 0.15s ease;
359
+ }
360
+ .canvas-drop-target.active { opacity: 1; pointer-events: auto; }
361
+ .canvas-drop-icon { font-size: 30px; color: var(--accent); line-height: 1; }
362
+ .canvas-drop-label { font-size: 14px; font-weight: 600; color: var(--text); letter-spacing: 0.01em; }
363
+ .canvas-drop-sub { font-size: 11px; color: var(--text-muted); }
364
+ @media (prefers-reduced-motion: reduce) { .canvas-drop-target { transition: none; } }
350
365
  .topology {
351
366
  flex: 1;
352
367
  display: flex;
@@ -712,6 +727,7 @@
712
727
 
713
728
  /* ========== FAVORITES BAR ========== */
714
729
  .fav-bar {
730
+ position: relative; /* anchors the .fav-bar-drop-label overlay (#71) */
715
731
  border-top: 1px solid var(--border);
716
732
  background: var(--surface);
717
733
  padding: 9px 16px;
@@ -720,7 +736,7 @@
720
736
  gap: 12px;
721
737
  min-height: 46px;
722
738
  overflow-x: auto;
723
- transition: background 0.15s, border-top-color 0.15s;
739
+ transition: background 0.15s, border-top-color 0.15s, border-top-style 0.15s;
724
740
  }
725
741
  .fav-bar-label {
726
742
  font-size: 10px;
@@ -735,11 +751,38 @@
735
751
  .fav-bar-label .star { color: var(--star); font-size: 12px; }
736
752
  .fav-bar-empty { font-style: italic; font-size: 11px; color: var(--text-dim); }
737
753
  .fav-chip-row { display: flex; gap: 6px; align-items: center; flex-wrap: nowrap; }
754
+ /* Two-level drop affordance (#71): the bar ADVERTISES itself as a drop target the
755
+ moment a node-card drag starts (.drop-armed — a quiet dashed accent edge), then
756
+ INTENSIFIES to the full accent fill when the node is dragged over it (.drag-over).
757
+ Both use existing tokens only; cleared on drop/cancel. */
758
+ .fav-bar.drop-armed {
759
+ background: var(--surface-2);
760
+ border-top: 1px dashed var(--accent-dim);
761
+ }
738
762
  .fav-bar.drag-over {
739
763
  background: var(--accent-soft);
740
- border-top-color: var(--accent);
764
+ border-top: 1px solid var(--accent);
741
765
  box-shadow: inset 0 1px 0 var(--accent);
742
766
  }
767
+ /* "Drop to save as Template" hint, shown while armed (and whether or not chips already
768
+ exist — it overlays flush-right, never reflows the chip row). The bar's own fill is
769
+ confirmation enough once the node is over it, so it fades out on .drag-over. */
770
+ .fav-bar-drop-label {
771
+ position: absolute;
772
+ right: 16px;
773
+ top: 50%;
774
+ transform: translateY(-50%);
775
+ font-size: 10px;
776
+ font-style: italic;
777
+ color: var(--accent-dim);
778
+ white-space: nowrap;
779
+ pointer-events: none;
780
+ transition: opacity 0.12s;
781
+ }
782
+ .fav-bar.drag-over .fav-bar-drop-label { opacity: 0; }
783
+ @media (prefers-reduced-motion: reduce) {
784
+ .fav-bar, .fav-bar.drop-armed, .fav-bar.drag-over, .fav-bar-drop-label { transition: none; }
785
+ }
743
786
  .fav-chip {
744
787
  display: inline-flex;
745
788
  align-items: center;
@@ -779,6 +822,19 @@
779
822
  }
780
823
  .fav-chip:hover .del { opacity: 1; }
781
824
  .fav-chip .del:hover { color: var(--err); background: rgba(248, 113, 113, 0.1); }
825
+ /* Edit (rename / recategorize) — mirrors .del, revealed on chip hover, but goes
826
+ accent-blue rather than err-red so the two actions read distinctly (#68). */
827
+ .fav-chip .edit {
828
+ color: var(--text-dim);
829
+ opacity: 0.4;
830
+ cursor: pointer;
831
+ padding: 4px 4px;
832
+ line-height: 1;
833
+ transition: all 0.15s;
834
+ border-radius: 2px;
835
+ }
836
+ .fav-chip:hover .edit { opacity: 1; }
837
+ .fav-chip .edit:hover { color: var(--accent); background: var(--accent-soft); }
782
838
 
783
839
  /* ========== INSPECT ========== */
784
840
  .inspect {
@@ -2221,9 +2277,11 @@ body {
2221
2277
  }
2222
2278
 
2223
2279
  /* Report + input nodes on the canvas carry a first-class action button
2224
- ("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut. */
2225
- .agent-card.report-node,
2226
- .agent-card.input-node { cursor: pointer; }
2280
+ ("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut.
2281
+ The card BODY keeps the shared `grab` cursor (every card is draggable) — only the
2282
+ actionable bits (.node-action, .inspect-hint, .fav-btn) read as `pointer`. Earlier
2283
+ these cards forced `cursor: pointer` on the whole body, which clashed with the grab
2284
+ cursor on every other card (#70). */
2227
2285
  .agent-card .node-action {
2228
2286
  display: block; width: 100%; margin-top: 8px;
2229
2287
  padding: 5px 8px; border-radius: 6px;
package/dist/web/app.js CHANGED
@@ -19,6 +19,7 @@ const state = {
19
19
  favorites: [],
20
20
  collapse: { left: false, right: false },
21
21
  pendingFavAgentId: null,
22
+ editingTemplateId: null, // set while the add/edit Template modal is in EDIT mode (#68)
22
23
  traceFilter: null, // Execution tab: node id to filter rows to, or null = all
23
24
  };
24
25
 
@@ -614,6 +615,7 @@ function renderCategorySuggestions() {
614
615
  function closeAddFavModal() {
615
616
  $addFavModal.classList.remove('show');
616
617
  state.pendingFavAgentId = null;
618
+ state.editingTemplateId = null; // leave EDIT mode so the next open is a fresh create (#68)
617
619
  }
618
620
 
619
621
  function commitFav() {
@@ -675,17 +677,37 @@ function refreshFavMarkers() {
675
677
  }
676
678
 
677
679
  function setupFavDropZone() {
680
+ // Advertise the Templates bar as a drop target the moment ANY node-card drag starts,
681
+ // so drag-to-save is discoverable instead of invisible (#71). dragend fires for both a
682
+ // completed drop AND a cancelled (Esc / dropped-nowhere) drag, so it's the disarm hook.
683
+ document.addEventListener('dragstart', (e) => {
684
+ if (!(e.target instanceof Element) || !e.target.closest('.agent-card')) return;
685
+ $favBar.classList.add('drop-armed');
686
+ // The teaching label rides in the empty bar's free space. Once templates exist the
687
+ // chip row can fill (and horizontally scroll) the bar, so an overlaid label would
688
+ // collide with chips — there the dashed armed edge + hover-intensify is the affordance.
689
+ if (state.favorites.length === 0 && !$favBar.querySelector('.fav-bar-drop-label')) {
690
+ const lbl = document.createElement('span');
691
+ lbl.className = 'fav-bar-drop-label';
692
+ lbl.textContent = '▾ Drop to save as Template';
693
+ $favBar.appendChild(lbl);
694
+ }
695
+ });
696
+ document.addEventListener('dragend', disarmFavBar);
697
+
678
698
  $favBar.addEventListener('dragover', (e) => {
679
699
  e.preventDefault();
680
700
  e.dataTransfer.dropEffect = 'copy';
681
701
  $favBar.classList.add('drag-over');
682
702
  });
703
+ // relatedTarget containment, not `e.target === $favBar`: the latter cleared the
704
+ // highlight prematurely when the cursor crossed from the bar's padding onto a chip.
683
705
  $favBar.addEventListener('dragleave', (e) => {
684
- if (e.target === $favBar) $favBar.classList.remove('drag-over');
706
+ if (!$favBar.contains(e.relatedTarget)) $favBar.classList.remove('drag-over');
685
707
  });
686
708
  $favBar.addEventListener('drop', (e) => {
687
709
  e.preventDefault();
688
- $favBar.classList.remove('drag-over');
710
+ disarmFavBar();
689
711
  const id = e.dataTransfer.getData('text/plain');
690
712
  if (!id || !AGENTS[id]) return;
691
713
  if (state.favorites.some(f => f.id === id)) {
@@ -697,6 +719,13 @@ function setupFavDropZone() {
697
719
  });
698
720
  }
699
721
 
722
+ // Clear every drag-affordance state on the Templates bar (drop, cancel, or dragend).
723
+ function disarmFavBar() {
724
+ $favBar.classList.remove('drop-armed', 'drag-over');
725
+ const lbl = $favBar.querySelector('.fav-bar-drop-label');
726
+ if (lbl) lbl.remove();
727
+ }
728
+
700
729
  /* ============= LIBRARY ============= */
701
730
 
702
731
  function openLibrary() {
@@ -922,8 +951,8 @@ document.querySelectorAll('.panel-toggle').forEach(btn => {
922
951
  document.getElementById('fav-cancel').onclick = closeAddFavModal;
923
952
  document.getElementById('fav-save').onclick = commitFav;
924
953
  document.getElementById('lib-close').onclick = () => $libModal.classList.remove('show');
925
- $addFavModal.onclick = (e) => { if (e.target === $addFavModal) closeAddFavModal(); };
926
- $libModal.onclick = (e) => { if (e.target === $libModal) $libModal.classList.remove('show'); };
954
+ onBackdropDismiss($addFavModal, closeAddFavModal);
955
+ onBackdropDismiss($libModal, () => $libModal.classList.remove('show'));
927
956
 
928
957
  document.addEventListener('keydown', (e) => {
929
958
  if (e.key === 'Escape') {
@@ -952,6 +981,22 @@ function escapeHtml(s) {
952
981
  }
953
982
  function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }
954
983
 
984
+ // Backdrop-dismiss that ignores text-selection drags. A modal closes only when BOTH
985
+ // the mousedown AND the click land on the backdrop element itself — so selecting text
986
+ // inside a modal input and releasing the cursor outside the modal no longer dismisses
987
+ // it on mouseup (#69). `guard` (optional) can veto dismissal, e.g. while a run is busy.
988
+ // Defined here (app.js loads first) and reused by aware.js for every modal.
989
+ function onBackdropDismiss(modal, dismiss, guard) {
990
+ if (!modal) return;
991
+ let downOnBackdrop = false;
992
+ modal.onmousedown = (e) => { downOnBackdrop = (e.target === modal); };
993
+ modal.onclick = (e) => {
994
+ const hit = e.target === modal && downOnBackdrop;
995
+ downOnBackdrop = false;
996
+ if (hit && (!guard || guard())) dismiss();
997
+ };
998
+ }
999
+
955
1000
  /* ============= MENU ============= */
956
1001
 
957
1002
  const $menuBtn = document.getElementById('menu-btn');
@@ -1329,7 +1374,7 @@ const $integrationsModal = document.getElementById('integrations-modal');
1329
1374
  const $integrationsList = document.getElementById('integrations-list');
1330
1375
 
1331
1376
  document.getElementById('integrations-close').onclick = () => $integrationsModal.classList.remove('show');
1332
- $integrationsModal.onclick = (e) => { if (e.target === $integrationsModal) $integrationsModal.classList.remove('show'); };
1377
+ onBackdropDismiss($integrationsModal, () => $integrationsModal.classList.remove('show'));
1333
1378
 
1334
1379
  /* ============= KEYBOARD SHORTCUTS ============= */
1335
1380
 
package/dist/web/aware.js CHANGED
@@ -1340,7 +1340,7 @@
1340
1340
  $save.onclick = () => done('save');
1341
1341
  $dont.onclick = () => done('discard');
1342
1342
  $cancel.onclick = () => done('cancel');
1343
- $confirmModal.onclick = (e) => { if (e.target === $confirmModal) done('cancel'); };
1343
+ onBackdropDismiss($confirmModal, () => done('cancel'));
1344
1344
  document.addEventListener('keydown', onKey, true);
1345
1345
  });
1346
1346
  }
@@ -1510,7 +1510,7 @@
1510
1510
  };
1511
1511
  $ok.onclick = () => done(collect());
1512
1512
  $cancel.onclick = () => done(null);
1513
- $formModal.onclick = (e) => { if (e.target === $formModal) done(null); };
1513
+ onBackdropDismiss($formModal, () => done(null));
1514
1514
  $body.onkeydown = (e) => {
1515
1515
  if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
1516
1516
  else if (e.key === 'Escape') { e.preventDefault(); done(null); }
@@ -1535,7 +1535,7 @@
1535
1535
  // BEHIND the report modal's backdrop, so a session shown in the Viewer needs its
1536
1536
  // own reachable Stop. Visibility is driven by syncRunControls (foregroundTrigger).
1537
1537
  if ($reportStop) $reportStop.onclick = () => stopRun();
1538
- $reportModal.addEventListener('click', (e) => { if (e.target === $reportModal) hideModal($reportModal); });
1538
+ onBackdropDismiss($reportModal, () => hideModal($reportModal));
1539
1539
  // The Stop button is rebuilt into the overlay each run — delegate so one
1540
1540
  // listener survives every innerHTML swap.
1541
1541
  $reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
@@ -1674,7 +1674,7 @@
1674
1674
 
1675
1675
  document.getElementById('bake-cancel').onclick = () => hideModal($bakeModal);
1676
1676
  document.getElementById('bake-confirm').onclick = () => runBake();
1677
- $bakeModal.onclick = (e) => { if (e.target === $bakeModal && !document.getElementById('bake-confirm').disabled) hideModal($bakeModal); };
1677
+ onBackdropDismiss($bakeModal, () => hideModal($bakeModal), () => !document.getElementById('bake-confirm').disabled);
1678
1678
 
1679
1679
  // ── Graft into agent ────────────────────────────────────────────────────────
1680
1680
  // Build an agent from a foreign tool (DLL / C# source / NuGet / OpenAPI / …).
@@ -1897,7 +1897,7 @@
1897
1897
  $graftBack.onclick = () => { if (graftState.onBack) graftState.onBack(); };
1898
1898
  $graftCancel.onclick = () => hideModal($graftModal);
1899
1899
  $graftPrimary.onclick = () => { if (graftState.onPrimary) graftState.onPrimary(); };
1900
- $graftModal.onclick = (e) => { if (e.target === $graftModal && !$graftCancel.disabled) hideModal($graftModal); };
1900
+ onBackdropDismiss($graftModal, () => hideModal($graftModal), () => !$graftCancel.disabled);
1901
1901
 
1902
1902
  // Secondary entry: "⊕ Graft new agent" inside the Agents Library modal.
1903
1903
  const $libGraft = document.getElementById('lib-graft');
@@ -1910,9 +1910,77 @@
1910
1910
  handleMenuAction = function (action) {
1911
1911
  if (action === 'graft') { openGraftModal(); return; }
1912
1912
  if (action === 'bake') { openBakeModal(); return; }
1913
+ if (action === 'import') { triggerImport(); return; }
1913
1914
  _handleMenuAction(action);
1914
1915
  };
1915
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
+ // Prefer the File System Access API: its types[].description names the OS picker's filter
1923
+ // ("FloLess workflow"), whereas a plain <input accept=…> with several extensions renders as
1924
+ // the generic "Custom Files". Fall back to the hidden <input> where the API is absent
1925
+ // (Firefox/Safari). Called synchronously from the menu-click gesture (no await before the
1926
+ // picker call), so the required user-activation is preserved.
1927
+ async function triggerImport() {
1928
+ if (typeof window.showOpenFilePicker === 'function') {
1929
+ let handle;
1930
+ try {
1931
+ [handle] = await window.showOpenFilePicker({
1932
+ types: [{ description: 'FloLess workflow', accept: { 'text/plain': ['.flo', '.app', '.flow', '.aware'] } }],
1933
+ multiple: false,
1934
+ });
1935
+ } catch { return; } // the user dismissed the picker (AbortError) — nothing to import
1936
+ try { importFlo(await handle.getFile()); } catch { showToast('Could not read that file', 'err'); }
1937
+ return;
1938
+ }
1939
+ if ($importFile) $importFile.click();
1940
+ }
1941
+
1942
+ async function importFlo(file) {
1943
+ if (!file) return;
1944
+ let content;
1945
+ try { content = await file.text(); } catch { showToast('Could not read that file', 'err'); return; }
1946
+ try {
1947
+ const { id } = await api('/api/import', { method: 'POST', body: JSON.stringify({ filename: file.name, content }) });
1948
+ await loadApps();
1949
+ $promptSel.value = id;
1950
+ $promptSel.dispatchEvent(new Event('change', { bubbles: true })); // → loadApp(id), arms Compile
1951
+ showToast(`Imported "${id}" — Compile to run`, 'ok');
1952
+ } catch (e) {
1953
+ const body = e && e.body;
1954
+ const msg = (body && body.error) || (e && e.message) || 'Import failed';
1955
+ showToast(msg, body && body.code === 'exists' ? 'warn' : 'err');
1956
+ }
1957
+ }
1958
+
1959
+ if ($importFile) {
1960
+ $importFile.onchange = () => {
1961
+ const f = $importFile.files && $importFile.files[0];
1962
+ $importFile.value = ''; // reset so re-picking the same filename still fires change
1963
+ importFlo(f);
1964
+ };
1965
+ }
1966
+
1967
+ // OS-file drop on the canvas. Distinguished from in-app node-card HTML5 drags (which
1968
+ // carry no "Files" type) so dragging a card still drops onto the Templates bar (#71).
1969
+ const $canvasMain = document.getElementById('canvas-main');
1970
+ const $dropTarget = document.getElementById('canvas-drop-target');
1971
+ const isFileDrag = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
1972
+ if ($canvasMain && $dropTarget) {
1973
+ $canvasMain.addEventListener('dragenter', (e) => { if (isFileDrag(e)) { e.preventDefault(); $dropTarget.classList.add('active'); } });
1974
+ $canvasMain.addEventListener('dragover', (e) => { if (isFileDrag(e)) e.preventDefault(); }); // required to allow the drop
1975
+ $canvasMain.addEventListener('dragleave', (e) => { if (!$canvasMain.contains(e.relatedTarget)) $dropTarget.classList.remove('active'); });
1976
+ $canvasMain.addEventListener('drop', (e) => {
1977
+ if (!isFileDrag(e)) return; // let node-card drops fall through to the Templates bar
1978
+ e.preventDefault();
1979
+ $dropTarget.classList.remove('active');
1980
+ importFlo(e.dataTransfer.files && e.dataTransfer.files[0]);
1981
+ });
1982
+ }
1983
+
1916
1984
  // ── Release notes: shared state + popover ─────────────────────────────────────
1917
1985
  // The channel-correct public site base (e.g. https://floless.io), captured from
1918
1986
  // /api/health in the health poll. The changelog deep-link is omitted entirely when
@@ -2985,6 +3053,9 @@
2985
3053
  state.hasRun = true;
2986
3054
  } else if (m.type === 'templates-changed') {
2987
3055
  loadTemplates().catch(() => {});
3056
+ } else if (m.type === 'apps-changed') {
3057
+ // A workflow was imported (here or in another tab) → refresh the picker (#66).
3058
+ loadApps().catch(() => {});
2988
3059
  } else if (m.type === 'baked' && m.id === currentId) {
2989
3060
  // Baked (here or in another tab) → refresh so the menu item flips to "Re-bake".
2990
3061
  loadApp(currentId).catch(() => {});
@@ -3100,8 +3171,9 @@
3100
3171
 
3101
3172
  openAddFavModal = function openAddFavModalTpl(nodeId) {
3102
3173
  state.pendingFavAgentId = nodeId;
3174
+ state.editingTemplateId = null; // CREATE mode
3103
3175
  const a = AGENTS[nodeId];
3104
- $addFavSub.textContent = `Save "${nodeId}" as a reusable template — usable in any project.`;
3176
+ setFavModalChrome('Save as Template', `Save "${nodeId}" as a reusable template — usable in any project.`, '★ Save');
3105
3177
  $favName.value = a ? a.title : nodeId;
3106
3178
  $favCat.value = '';
3107
3179
  renderCategorySuggestions();
@@ -3109,7 +3181,47 @@
3109
3181
  setTimeout(() => $favCat.focus(), 50);
3110
3182
  };
3111
3183
 
3184
+ // Swap the shared Add-Template modal's title / subtitle / save-button label so the
3185
+ // same modal serves both CREATE (from a node) and EDIT (rename/recategorize) (#68).
3186
+ function setFavModalChrome(title, sub, saveLabel) {
3187
+ const $t = document.getElementById('add-fav-title');
3188
+ const $s = document.getElementById('fav-save');
3189
+ if ($t) $t.textContent = title;
3190
+ $addFavSub.textContent = sub;
3191
+ if ($s) $s.textContent = saveLabel;
3192
+ }
3193
+
3194
+ // Open the modal in EDIT mode for an existing template. Only name + category are
3195
+ // editable; the captured node is immutable (re-star a node to change its logic) (#68).
3196
+ function openEditTemplate(id) {
3197
+ const tpl = state.favorites.find((t) => t.id === id);
3198
+ if (!tpl) return;
3199
+ state.editingTemplateId = id;
3200
+ state.pendingFavAgentId = null;
3201
+ setFavModalChrome('Edit Template', 'Rename or recategorize this template.', 'Save changes');
3202
+ $favName.value = tpl.name;
3203
+ $favCat.value = tpl.category;
3204
+ renderCategorySuggestions();
3205
+ $addFavModal.classList.add('show');
3206
+ setTimeout(() => { $favName.focus(); $favName.select(); }, 50);
3207
+ }
3208
+
3112
3209
  commitFav = async function commitFavTpl() {
3210
+ // EDIT mode (#68): PATCH the existing template's name/category; node is untouched.
3211
+ if (state.editingTemplateId) {
3212
+ const id = state.editingTemplateId;
3213
+ const name = ($favName.value || '').trim();
3214
+ const category = ($favCat.value || '').trim() || 'Uncategorized';
3215
+ if (!name) { showToast('Name required', 'warn'); return; }
3216
+ try {
3217
+ await api(`/api/templates/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, category }) });
3218
+ $addFavModal.classList.remove('show');
3219
+ state.editingTemplateId = null;
3220
+ await loadTemplates();
3221
+ showToast(`Updated template "${name}"`, 'ok');
3222
+ } catch (e) { reportErr(e); }
3223
+ return;
3224
+ }
3113
3225
  const nodeId = state.pendingFavAgentId;
3114
3226
  if (!nodeId) return;
3115
3227
  const name = ($favName.value || '').trim() || nodeId;
@@ -3144,14 +3256,16 @@
3144
3256
  if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
3145
3257
  $favBarEmpty.style.display = 'none';
3146
3258
  $favChipRow.innerHTML = tpls.map((t) => `
3147
- <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 : ''))}">
3259
+ <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 : ''))}">
3148
3260
  <span class="cat">${escapeHtml(t.category)}</span>
3149
3261
  <span class="name">${escapeHtml(t.name)}</span>
3150
- <span class="del" data-tip="Delete template">×</span>
3262
+ <span class="edit" data-tip="Rename / recategorize" aria-label="Edit template">✎</span>
3263
+ <span class="del" data-tip="Delete template" aria-label="Delete template">×</span>
3151
3264
  </div>`).join('');
3152
3265
  $favChipRow.querySelectorAll('.fav-chip').forEach((chip) => {
3153
3266
  const id = chip.dataset.tpl;
3154
- chip.onclick = (e) => { if (e.target.closest('.del')) return; useTemplate(id); };
3267
+ chip.onclick = (e) => { if (e.target.closest('.del') || e.target.closest('.edit')) return; useTemplate(id); };
3268
+ chip.querySelector('.edit').onclick = (e) => { e.stopPropagation(); openEditTemplate(id); };
3155
3269
  chip.querySelector('.del').onclick = (e) => { e.stopPropagation(); deleteTemplate(id); };
3156
3270
  });
3157
3271
  };
@@ -3188,6 +3302,32 @@
3188
3302
  return '';
3189
3303
  }
3190
3304
 
3305
+ // Which product skill applies each request type (named in the copied marker so the
3306
+ // terminal AI picks up the right one).
3307
+ const REQUEST_SKILL = {
3308
+ 'use-template': 'floless-app-workflows',
3309
+ tweak: 'floless-app-workflows',
3310
+ 'ui-customize': 'floless-app-ui',
3311
+ rebake: 'floless-app-rebake',
3312
+ };
3313
+
3314
+ // The COPIED form of a request: instructionFor() prefixed with a self-identifying
3315
+ // marker so a PASTED request is unmistakable to the terminal AI — it's a queued FloLess
3316
+ // Dashboard request to APPLY (via the named skill + the authoritative /api/requests),
3317
+ // not a literal instruction to run verbatim (#73). The modal preview keeps the plain
3318
+ // instructionFor() text; only the clipboard carries the marker.
3319
+ function markedInstruction(req) {
3320
+ const body = instructionFor(req);
3321
+ if (!body) return '';
3322
+ const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
3323
+ const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
3324
+ const marker =
3325
+ `[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
3326
+ `Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
3327
+ `apply that request, then DELETE ${base}/api/requests/${req.id}. Don't run the line below verbatim.`;
3328
+ return `${marker}\n${body}`;
3329
+ }
3330
+
3191
3331
  async function copyToClipboard(text) {
3192
3332
  try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
3193
3333
  }
@@ -3196,7 +3336,7 @@
3196
3336
  if (!currentId) { showToast('open a workflow first', 'warn'); return; }
3197
3337
  try {
3198
3338
  const { request } = await api('/api/use-template', { method: 'POST', body: JSON.stringify({ appId: currentId, templateId }) });
3199
- const line = instructionFor(request);
3339
+ const line = markedInstruction(request);
3200
3340
  const copied = await copyToClipboard(line);
3201
3341
  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.'}`);
3202
3342
  showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
@@ -3221,7 +3361,7 @@
3221
3361
  try {
3222
3362
  const snaps = Array.isArray(res.snapshots) ? res.snapshots.map((s) => ({ name: s.name, dataUrl: s.dataUrl })) : [];
3223
3363
  const { request } = await api('/api/tweak', { method: 'POST', body: JSON.stringify({ appId: currentId, nodeId: node, instruction, snapshots: snaps }) });
3224
- const copied = await copyToClipboard(instructionFor(request));
3364
+ const copied = await copyToClipboard(markedInstruction(request));
3225
3365
  appendNarration(`Tweak queued for <code>${escapeHtml(node)}</code> — your terminal AI can pull it (floless skill) ${copied ? 'or paste the copied instruction' : ''}.`);
3226
3366
  const toastMsg = snaps.length
3227
3367
  ? `Tweak + ${snaps.length} snapshot(s) queued${copied ? ' + copied' : ''}`
@@ -3289,7 +3429,7 @@
3289
3429
  b.onclick = async () => {
3290
3430
  const r = pendingRequests.find((x) => x.id === b.dataset.id);
3291
3431
  if (!r) return;
3292
- const copied = await copyToClipboard(instructionFor(r));
3432
+ const copied = await copyToClipboard(markedInstruction(r));
3293
3433
  showToast(copied ? 'Copied — paste it to your terminal AI' : 'copy failed', copied ? 'ok' : 'err');
3294
3434
  };
3295
3435
  });
@@ -3975,7 +4115,7 @@
3975
4115
  const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
3976
4116
  $confirm.onclick = () => done(true);
3977
4117
  $cancel.onclick = () => done(false);
3978
- $m.onclick = (e) => { if (e.target === $m) done(false); };
4118
+ onBackdropDismiss($m, () => done(false));
3979
4119
  document.addEventListener('keydown', onKey, true);
3980
4120
  });
3981
4121
  }
@@ -4167,11 +4307,11 @@
4167
4307
  // Wiring (the modal elements are static in index.html, present when this runs).
4168
4308
  document.getElementById('routines-btn').onclick = () => openRoutines();
4169
4309
  document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
4170
- $routinesModal.onclick = (e) => { if (e.target === $routinesModal) hideModal($routinesModal); };
4310
+ onBackdropDismiss($routinesModal, () => hideModal($routinesModal));
4171
4311
  document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
4172
4312
  document.getElementById('rtn-edit-cancel').onclick = () => hideModal($routineEditModal);
4173
4313
  document.getElementById('rtn-edit-save').onclick = () => saveRoutine();
4174
- $routineEditModal.onclick = (e) => { if (e.target === $routineEditModal) hideModal($routineEditModal); };
4314
+ onBackdropDismiss($routineEditModal, () => hideModal($routineEditModal));
4175
4315
  document.getElementById('rtn-kind').onchange = (e) => applySchedKind(e.target.value);
4176
4316
  document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => { b.onclick = () => setRoutineMode(b.dataset.mode); });
4177
4317
  document.getElementById('rtn-workflow').onchange = (e) => { if (!editingRoutineId) loadRoutineInputs(e.target.value, null); };
@@ -4359,7 +4499,7 @@
4359
4499
  $riDesc.addEventListener('input', riSyncSend);
4360
4500
  $riSend.onclick = () => riSubmit();
4361
4501
  $riCancel.onclick = () => riClose();
4362
- $riModal.onclick = (e) => { if (e.target === $riModal) riClose(); };
4502
+ onBackdropDismiss($riModal, () => riClose());
4363
4503
  $riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
4364
4504
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
4365
4505
 
@@ -4402,13 +4542,13 @@
4402
4542
  const $reqModal = document.getElementById('requests-modal');
4403
4543
  const $reqClose = document.getElementById('requests-close');
4404
4544
  if ($reqClose) $reqClose.onclick = () => $reqModal.classList.remove('show');
4405
- if ($reqModal) $reqModal.onclick = (e) => { if (e.target === $reqModal) $reqModal.classList.remove('show'); };
4545
+ if ($reqModal) onBackdropDismiss($reqModal, () => $reqModal.classList.remove('show'));
4406
4546
  const $reqClear = document.getElementById('requests-clear');
4407
4547
  if ($reqClear) $reqClear.onclick = () => clearAllRequests();
4408
4548
  const $reqCopy = document.getElementById('requests-copy');
4409
4549
  if ($reqCopy) $reqCopy.onclick = async () => {
4410
4550
  if (!pendingRequests.length) return;
4411
- const text = pendingRequests.map(instructionFor).filter(Boolean).join('\n\n');
4551
+ const text = pendingRequests.map(markedInstruction).filter(Boolean).join('\n\n');
4412
4552
  const copied = await copyToClipboard(text);
4413
4553
  showToast(copied ? `Copied ${pendingRequests.length} request(s) — paste to your terminal AI` : 'copy failed', copied ? 'ok' : 'err');
4414
4554
  };
@@ -4561,6 +4701,7 @@
4561
4701
  loadRequests,
4562
4702
  copyToClipboard,
4563
4703
  instructionFor,
4704
+ markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
4564
4705
  };
4565
4706
 
4566
4707
  // ── boot ──────────────────────────────────────────────────────────────────────
@@ -95,6 +95,12 @@
95
95
  <span class="role" id="center-panel-role">transparency layer · read-mostly</span>
96
96
  </span>
97
97
  </div>
98
+ <div class="canvas-drop-target" id="canvas-drop-target" aria-hidden="true">
99
+ <div class="canvas-drop-icon" aria-hidden="true">⤓</div>
100
+ <div class="canvas-drop-label">Drop a .flo file to import</div>
101
+ <div class="canvas-drop-sub">The workflow installs and is added to your picker</div>
102
+ </div>
103
+ <input type="file" id="import-file" accept=".flo,.app,.flow,.aware" hidden>
98
104
  <div class="find-overlay" id="find-overlay">
99
105
  <input type="text" id="find-input" placeholder="Find agent…">
100
106
  <span class="find-count" id="find-count"></span>
@@ -229,6 +235,10 @@
229
235
  <span class="menu-label">Save inputs</span>
230
236
  <span class="menu-kbd">Ctrl+S</span>
231
237
  </button>
238
+ <button class="menu-item" data-action="import" role="menuitem">
239
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0-4-4m4 4 4-4"/><path d="M3 17v2a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2"/></svg></span>
240
+ <span class="menu-label">Import workflow…</span>
241
+ </button>
232
242
  <div class="menu-divider"></div>
233
243
  <button class="menu-item" data-action="find" role="menuitem">
234
244
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg></span>
@@ -347,7 +357,7 @@
347
357
 
348
358
  <div class="modal-backdrop" id="add-fav-modal">
349
359
  <div class="modal">
350
- <div class="modal-title">Save as Template</div>
360
+ <div class="modal-title" id="add-fav-title">Save as Template</div>
351
361
  <div class="modal-sub" id="add-fav-sub">Save this node as a reusable Template — usable in any workflow.</div>
352
362
  <div class="modal-field">
353
363
  <label for="fav-name">Name</label>
@@ -421,7 +421,9 @@
421
421
  if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
422
422
  try {
423
423
  const { request } = await api('/api/extensions/customize', { method: 'POST', body: JSON.stringify({ instruction }) });
424
- const line = bridge.instructionFor ? bridge.instructionFor(request) : instruction;
424
+ // Copy the MARKED form so a pasted ui-customize request is recognized, not run verbatim (#73).
425
+ const line = bridge.markedInstruction ? bridge.markedInstruction(request)
426
+ : bridge.instructionFor ? bridge.instructionFor(request) : instruction;
425
427
  const copied = bridge.copyToClipboard ? await bridge.copyToClipboard(line) : false;
426
428
  showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
427
429
  if (input) input.value = '';
@@ -544,14 +546,14 @@
544
546
  setTimeout(() => $cancel.focus(), 0);
545
547
  const done = (result) => {
546
548
  $resetModal.classList.remove('show');
547
- $confirm.onclick = $cancel.onclick = $resetModal.onclick = null;
549
+ $confirm.onclick = $cancel.onclick = $resetModal.onclick = $resetModal.onmousedown = null;
548
550
  document.removeEventListener('keydown', onKey, true);
549
551
  resolve(result);
550
552
  };
551
553
  const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
552
554
  $confirm.onclick = () => done(true);
553
555
  $cancel.onclick = () => done(false);
554
- $resetModal.onclick = (e) => { if (e.target === $resetModal) done(false); };
556
+ onBackdropDismiss($resetModal, () => done(false));
555
557
  document.addEventListener('keydown', onKey, true);
556
558
  });
557
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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": {