@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.
@@ -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
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: floless-app-rebake
3
+ description: This skill should be used when the user swaps the source drawing of a BAKED floless.app workflow and wants it re-read — the "Re-read & re-bake" (B3) loop for Visual Inputs. Triggers on a pending floless `rebake` request (queued from the app's "Re-read & re-bake ▸" button), or asks like "re-bake screenshot-to-saved-settings with this new schedule", "I swapped the drawing, re-read it", "re-extract and re-bake the config". It teaches the host AI to read the new image, re-extract its values, rewrite ONLY the app's config.yaml baked literals, recompile, and ask the user to approve — all at COMPOSE time (AWARE forbids reading a drawing during a run).
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Re-read & re-bake a baked Visual Input (B3)
9
+
10
+ ## What this is
11
+
12
+ Some floless.app workflows **bake** an image/PDF at compose time: the terminal AI reads a
13
+ drawing once and writes the extracted values into `config.yaml` as literals, so the run is
14
+ fully deterministic and reads no drawing (the **decalog-#9 / "bake"** strategy — AWARE's
15
+ `aware app validate` *rejects* a node that reads a drawing during a run). When the user wants
16
+ to process a **different** drawing, they can't just "swap and run" — the literals must be
17
+ re-extracted and re-baked. That re-extraction is an LLM act, so it happens **here, at compose
18
+ time**, then the user approves the new lock. This is strategy **B3** of the Visual Inputs design
19
+ (`docs/superpowers/specs/2026-06-12-visual-inputs-design.md`).
20
+
21
+ The floless.app UI surfaces a **"Re-read & re-bake ▸"** button on a baked-visual app's entry
22
+ node. Clicking it (after attaching a new drawing) records a **`rebake` request** that you pick
23
+ up here.
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
+
30
+ ## The loop
31
+
32
+ 1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
33
+ `type: "rebake"`. It carries `appId`, the `inputName` (the baked input, e.g. `drawing`),
34
+ an `instruction`, and `snapshots: ["<abs path to the new image>"]`. (The user may also just
35
+ ask in prose with an image attached — same procedure, skip the request lookup.)
36
+ 2. **Read the new drawing.** Open the image at `snapshots[0]` (a real PNG/JPEG/WebP on disk).
37
+ Read it with your own vision — this is the compose-time extraction.
38
+ 3. **Re-extract the same schema the app already bakes.** Open the app source +
39
+ `config.yaml` under `~/.aware/apps/<appId>/` (and the editable copy under `demos/<appId>/`
40
+ if present). Identify the baked literals (e.g. `config.schedule-rows`). Re-extract **the same
41
+ shape** from the new drawing — same fields, same types — so the deterministic nodes keep
42
+ working. Do NOT invent values the drawing doesn't state; leave the app's documented
43
+ sentinel/default for anything absent (mirror how it was originally baked).
44
+ 4. **Rewrite ONLY the baked config values.** Edit `~/.aware/apps/<appId>/config.yaml`
45
+ (and the `demos/<appId>/config.yaml` editable copy) — replace the baked literals with the
46
+ re-extracted ones. **Do not change node logic, the `.flo`, or the schema** — only the values.
47
+ 5. **Recompile.** `aware app compile ~/.aware/apps/<appId>` → writes a fresh `<appId>.lock`.
48
+ The source-hash changes, so floless's Run gate disarms until approval. Copy the lock back to
49
+ `demos/<appId>/` if you keep an editable copy.
50
+ 6. **Hand the approval to the user.** Tell them what changed (e.g. "re-baked 7 schedule rows
51
+ from the new drawing — review the diff in Inspect → Code, then Compile/Approve, then ▶ Run").
52
+ The human eyeballing the re-extraction before any run **is the safety gate** (it replaces the
53
+ `approve:` block AWARE's bake pattern recommends). Never auto-run.
54
+ 7. **Clear the request.** `DELETE http://localhost:<port>/api/requests/<id>` once done.
55
+
56
+ ## Guardrails
57
+
58
+ - **Compose-time only.** Never add a runtime node that reads the drawing — `aware app validate`
59
+ rejects it, and it would break determinism. The whole point of B3 is to keep the run
60
+ deterministic by re-baking up front.
61
+ - **Values, not logic.** Re-bake only `config.yaml` literals. If the new drawing needs a
62
+ *different shape* (new columns, a different schema), that's a workflow change, not a re-bake —
63
+ tell the user and use `floless-app-workflows` instead.
64
+ - **Thin-UI contract holds.** The browser only recorded intent + the image; the brain is you,
65
+ in the terminal. The UI never re-bakes and never reads the drawing.
66
+ - **Resolve the port** from the running floless.app (the `/api` base the user's workspace is on)
67
+ the same way the other floless-app skills do.
68
+
69
+ ## Relationship to the other strategies
70
+
71
+ - **A (bake)** is the first-time version of this (read at compose, bake). B3 is "do it again for a
72
+ new drawing." Same act, re-triggered by a swap.
73
+ - **B1 (parse)** is for deterministically-parseable inputs that DON'T need re-baking — there the
74
+ image is a true runtime input read by an `exec` node; the user swaps and runs with no re-bake.
75
+ - **B2 (vision)** — a curated runtime `vision.extract` agent — is the future zero-friction
76
+ replacement for B3 once AWARE ships it (RFC `aware-aeco/aware#223`). Until then, B3 is the
77
+ swap-and-(almost)-run answer for vision inputs.
@@ -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
@@ -44,7 +44,16 @@ floless extracts `data.result.html` for the HTML Viewer.
44
44
  `System.IO`, `System.Diagnostics.Debugger`, and the Tekla Open API
45
45
  (`Tekla.Structures.Model`, etc. — discovered/probed from the running install).
46
46
  - **NOT referenced:** `System.Net` (so **no** `WebUtility.HtmlEncode` — escape manually),
47
- `System.Diagnostics.Process`. Don't rely on them.
47
+ `System.Diagnostics.Process`, and **`System.Text.RegularExpressions`** (no `Regex` —
48
+ use `IndexOf`/`Substring` scans instead). Don't rely on them.
49
+
50
+ > **FOOTGUN — the bridge swallows the compiler diagnostic.** An exec script that fails
51
+ > to compile (e.g. references an unreferenced assembly like `Regex`) does NOT surface a
52
+ > `CS####` error — the run aborts with an opaque `agent tekla/exec failed (exit Some(2))`
53
+ > and an empty detail (verified on aware 0.65.0). It reads like a host-attach failure but
54
+ > is a compile error in your C#. Isolate by running a known-good Tekla-free exec
55
+ > (`dummy-report`): if that runs and yours exits 2, the fault is your script, not the
56
+ > bridge/host. (The opacity is an AWARE DX gap — candidate to file on aware-aeco.)
48
57
 
49
58
  Manual HTML escape (since `System.Net` is unavailable):
50
59
 
@@ -0,0 +1,94 @@
1
+ # Visual Inputs (image / PDF): one input, four read-strategies
2
+
3
+ A workflow takes an image/PDF via an input that declares **`widget: file`** (and
4
+ typically `type: image`). The user swaps it on the **input node** ("Set inputs ▸" →
5
+ a file picker); floless stores the bytes under `~/.floless/inputs/<app>/<sha>.<ext>`
6
+ (content-addressed, PDF-aware) and sets `{{ inputs.<name> }}` to that **path**. The
7
+ bytes never pass through a model in the UI — the web layer moves a file, AWARE (or
8
+ the terminal AI at compose) interprets it.
9
+
10
+ ```yaml
11
+ inputs:
12
+ drawing:
13
+ type: image
14
+ widget: file # ← load-bearing: renders the picker, not a text box
15
+ accept: [pdf, png, jpg] # narrows the picker + the chip glyph
16
+ read-strategy: parse # parse | bake | rebake | vision (see below)
17
+ description: The framing PDF/image to take off (swappable per run).
18
+ ```
19
+
20
+ ## Pick the strategy from two questions — needs vision? swappable per run?
21
+
22
+ | | deterministic parser exists | needs vision |
23
+ |---|---|---|
24
+ | **fixed / rare** | parse-once, or **bake** (A) | **bake** (A) |
25
+ | **swap per run** | **parse** (B1) | **rebake** (B3) today · **vision** (B2) later |
26
+
27
+ - **parse (B1)** — a read-only `exec` node opens `{{ inputs.<name> }}` with `System.IO`
28
+ and a deterministic library (PdfPig geometry, OCR, barcode). No LLM, legal at
29
+ runtime. The file is a true swappable input; swap + Run, no recompile. Reference:
30
+ `demos/tekla-pdf-takeoff/` (page-count today; geometry takeoff is the same shape).
31
+ - **bake (A)** — the terminal AI reads the artifact at **compose** time and bakes
32
+ literals into `config.yaml`; the run never reads it. Reference:
33
+ `demos/screenshot-to-saved-settings/`. **AWARE REQUIRES this for any vision read:**
34
+ `aware app validate` rejects a runtime think-node that reads a drawing
35
+ (`app-spec.md` §"Reading a drawing / PDF / image", and `validate.rs`).
36
+ - **rebake (B3)** — swapping the artifact re-triggers the compose-time bake via the
37
+ request relay; the **`floless-app-rebake` skill** teaches the host AI to re-read +
38
+ re-bake + recompile + approve. The baked input declares `read-strategy: bake`; the
39
+ entry node shows a **"Re-read & re-bake ▸"** affordance. Feels like swap-and-run,
40
+ keeps determinism. (Shipped — Sub-project 2.)
41
+ - **vision (B2)** — a curated runtime `vision.extract` agent (image → schema-bound
42
+ JSON, content-cached, approve-gated). Needs an AWARE substrate change — RFC
43
+ `aware-aeco/aware#223` (+ reference impl PR). **Substrate-gated:** until `aware`
44
+ ships `vision.extract`, authoring a B2 app fails `validate`; use **rebake (B3)** for
45
+ swap + vision in the meantime (a B2 input is a drop-in upgrade of a B3 input).
46
+
47
+ **Rule:** never put an LLM in the run path except B2's curated, lock-pinned,
48
+ approve-gated agent. The UI never interprets the file — it only stores it and passes
49
+ the path.
50
+
51
+ ## Authoring a B2 (vision) app — gated on AWARE `vision.extract`
52
+
53
+ A B2 app is a normal reusable `.flo` whose **source drawing is a runtime input** (the SP1
54
+ picker — `read-strategy: vision`, `widget: file`) feeding a **`vision.extract` node**, whose
55
+ schema-bound JSON then drives deterministic downstream nodes behind an `approve:` gate:
56
+
57
+ ```yaml
58
+ inputs:
59
+ drawing: { type: image, widget: file, read-strategy: vision, accept: [png, jpg, pdf],
60
+ description: The schedule drawing to extract (swappable per run). }
61
+ nodes:
62
+ - id: extract
63
+ agent: vision
64
+ command: extract # the curated carve-out — runs a model at RUN time
65
+ config:
66
+ file: "{{ inputs.drawing }}" # bytes on the edge
67
+ model: "claude-..." # pinned in the lock; a swap re-invalidates approval
68
+ schema: { type: object, properties: { rows: { type: array, ... } } } # FIXED output schema
69
+ prompt: "Extract each schedule row as {member, boltCount, ...}."
70
+ - id: write
71
+ # ...deterministic for-each over {{ extract.result.rows }}...
72
+ approve: { ... } # human eyeballs the extraction before any write
73
+ ```
74
+
75
+ **floless seams for B2 (mostly already in place from SP1):**
76
+ - The picker is the **SP1 file field** — a `read-strategy: vision` input already gets "Set
77
+ inputs ▸" and stores the path as `{{ inputs.drawing }}` (no new UI). Swap + Run; the
78
+ `vision.extract` node re-reads at runtime (content-cached, so a repeat input is replayed
79
+ from cache with no model call).
80
+ - When AWARE stamps a `runtime-model` marker on the compiled node (RFC §5.3), `app-reader`
81
+ surfaces it and the canvas badges **"calls a model at run time"** so the approver sees it
82
+ honestly — wire this against the real lock field once `vision.extract` lands.
83
+ - Gate the affordance/authoring on the installed `aware` version exposing `vision`
84
+ (`aware agent has vision extract`); degrade to B3 below the floor.
85
+
86
+ ## Server / UI seams (where this lives)
87
+
88
+ - `server/visual-input-store.ts` — content-addressed store (`storeVisualInput`),
89
+ PDF-aware magic-byte sniff, path-safety.
90
+ - `server/app-reader.ts` — `AppInput.widget/accept/readStrategy` + `isVisualInput`.
91
+ - `POST /api/visual-input` (`server/index.ts`) — `{appId, name, dataUrl}` → `{path}`.
92
+ - `web/aware.js` — `openInputsDialog` renders a `type:'file'` field (reuses `.fm-drop`);
93
+ on a fresh pick it uploads the data URL and stores the returned path as the input
94
+ value; `addNodeInputs` shows a `.ni-pair-file` chip (file glyph + basename).
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;
@@ -574,6 +589,16 @@
574
589
  word-break: break-word;
575
590
  }
576
591
  .agent-card .blurb { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; margin-bottom: 10px; }
592
+ /* B2: the one node that calls a model at run time (vision.extract). A subtle accent
593
+ pill so the approver sees the non-deterministic-on-first-input node honestly. */
594
+ .agent-card .rt-model-badge {
595
+ display: inline-flex; align-items: center; gap: 4px;
596
+ margin: 0 0 8px; padding: 2px 8px;
597
+ font: 600 10px/1.4 var(--ui); letter-spacing: 0.01em;
598
+ color: var(--accent); background: var(--accent-soft);
599
+ border: 1px solid var(--accent-dim); border-radius: 999px;
600
+ cursor: help;
601
+ }
577
602
  .agent-card .footer-row {
578
603
  display: flex;
579
604
  justify-content: space-between;
@@ -702,6 +727,7 @@
702
727
 
703
728
  /* ========== FAVORITES BAR ========== */
704
729
  .fav-bar {
730
+ position: relative; /* anchors the .fav-bar-drop-label overlay (#71) */
705
731
  border-top: 1px solid var(--border);
706
732
  background: var(--surface);
707
733
  padding: 9px 16px;
@@ -710,7 +736,7 @@
710
736
  gap: 12px;
711
737
  min-height: 46px;
712
738
  overflow-x: auto;
713
- transition: background 0.15s, border-top-color 0.15s;
739
+ transition: background 0.15s, border-top-color 0.15s, border-top-style 0.15s;
714
740
  }
715
741
  .fav-bar-label {
716
742
  font-size: 10px;
@@ -725,11 +751,38 @@
725
751
  .fav-bar-label .star { color: var(--star); font-size: 12px; }
726
752
  .fav-bar-empty { font-style: italic; font-size: 11px; color: var(--text-dim); }
727
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
+ }
728
762
  .fav-bar.drag-over {
729
763
  background: var(--accent-soft);
730
- border-top-color: var(--accent);
764
+ border-top: 1px solid var(--accent);
731
765
  box-shadow: inset 0 1px 0 var(--accent);
732
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
+ }
733
786
  .fav-chip {
734
787
  display: inline-flex;
735
788
  align-items: center;
@@ -769,6 +822,19 @@
769
822
  }
770
823
  .fav-chip:hover .del { opacity: 1; }
771
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); }
772
838
 
773
839
  /* ========== INSPECT ========== */
774
840
  .inspect {
@@ -2211,9 +2277,11 @@ body {
2211
2277
  }
2212
2278
 
2213
2279
  /* Report + input nodes on the canvas carry a first-class action button
2214
- ("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut. */
2215
- .agent-card.report-node,
2216
- .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). */
2217
2285
  .agent-card .node-action {
2218
2286
  display: block; width: 100%; margin-top: 8px;
2219
2287
  padding: 5px 8px; border-radius: 6px;
@@ -2366,6 +2434,23 @@ body {
2366
2434
  .fm-thumb-del { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; line-height: 1; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 2px; color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, border-color 0.12s; }
2367
2435
  .fm-thumb:hover .fm-thumb-del, .fm-thumb-del:focus-visible { opacity: 1; }
2368
2436
  .fm-thumb-del:hover { color: var(--err); border-color: var(--err); }
2437
+ /* ===== Visual Input: single image/PDF file field (inputs dialog) ===== */
2438
+ .fm-file { position: relative; }
2439
+ .fm-file .fm-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
2440
+ .fm-drop .fm-drop-hint { display: block; margin-top: 3px; font-size: 10px; color: var(--text-dim); }
2441
+ .fm-drop.has-file { min-height: auto; padding: 6px 10px; flex-direction: row; justify-content: space-between; gap: 8px; border-style: solid; cursor: default; text-align: left; }
2442
+ .fm-drop.has-file:hover, .fm-drop.has-file:focus-visible { border-color: var(--border-strong); background: var(--surface-2); color: var(--text-muted); outline: none; }
2443
+ .fm-file-glyph { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 18px; border: 1px solid var(--accent-dim); border-radius: 3px; background: var(--accent-soft); color: var(--accent); font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
2444
+ .fm-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--text); text-align: left; }
2445
+ .fm-file-actions { display: flex; gap: 5px; flex-shrink: 0; }
2446
+ .fm-file-replace, .fm-file-clear { font-size: 10px; padding: 3px 8px; background: var(--surface); border: 1px solid var(--border-strong); color: var(--text-muted); border-radius: 3px; cursor: pointer; font-family: var(--ui); }
2447
+ .fm-file-replace:hover { color: var(--text); border-color: var(--accent-dim); }
2448
+ .fm-file-clear:hover { color: var(--err); border-color: var(--err); }
2449
+ /* Visual Input chip on the canvas input node */
2450
+ .agent-card .node-inputs .ni-pair-file { align-items: center; }
2451
+ .agent-card .node-inputs .ni-file-glyph { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 15px; border: 1px solid var(--accent-dim); border-radius: 3px; background: var(--accent-soft); color: var(--accent); font: 700 8px/1 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
2452
+ .agent-card .node-inputs .ni-file-name { max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; }
2453
+ .agent-card .node-inputs .ni-not-set { color: var(--text-dim); font-style: italic; font-weight: 400; }
2369
2454
  /* ===== Requests: snapshot thumbnails ===== */
2370
2455
  .req-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
2371
2456
  .req-thumb { width: 46px; height: 46px; padding: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg); overflow: hidden; cursor: pointer; flex-shrink: 0; transition: border-color 0.15s; }
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
 
@@ -346,6 +347,7 @@ function cardEl(id, ports) {
346
347
  </div>
347
348
  <div class="title">${a.title}</div>
348
349
  <div class="subtitle">${a.subtitle}</div>
350
+ ${a._runtimeModel ? '<div class="rt-model-badge" title="This node calls a model at run time (vision.extract). Output is content-cached and sits behind an approve gate — review the extraction before any write.">⚡ calls a model at run time</div>' : ''}
349
351
  <div class="blurb">${a.blurb}</div>
350
352
  <div class="footer-row">
351
353
  <span class="ports"><span class="ports-num">${ports.in}</span> in <span class="ports-arrow">·</span> <span class="ports-num">${ports.out}</span> out</span>
@@ -613,6 +615,7 @@ function renderCategorySuggestions() {
613
615
  function closeAddFavModal() {
614
616
  $addFavModal.classList.remove('show');
615
617
  state.pendingFavAgentId = null;
618
+ state.editingTemplateId = null; // leave EDIT mode so the next open is a fresh create (#68)
616
619
  }
617
620
 
618
621
  function commitFav() {
@@ -674,17 +677,37 @@ function refreshFavMarkers() {
674
677
  }
675
678
 
676
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
+
677
698
  $favBar.addEventListener('dragover', (e) => {
678
699
  e.preventDefault();
679
700
  e.dataTransfer.dropEffect = 'copy';
680
701
  $favBar.classList.add('drag-over');
681
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.
682
705
  $favBar.addEventListener('dragleave', (e) => {
683
- if (e.target === $favBar) $favBar.classList.remove('drag-over');
706
+ if (!$favBar.contains(e.relatedTarget)) $favBar.classList.remove('drag-over');
684
707
  });
685
708
  $favBar.addEventListener('drop', (e) => {
686
709
  e.preventDefault();
687
- $favBar.classList.remove('drag-over');
710
+ disarmFavBar();
688
711
  const id = e.dataTransfer.getData('text/plain');
689
712
  if (!id || !AGENTS[id]) return;
690
713
  if (state.favorites.some(f => f.id === id)) {
@@ -696,6 +719,13 @@ function setupFavDropZone() {
696
719
  });
697
720
  }
698
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
+
699
729
  /* ============= LIBRARY ============= */
700
730
 
701
731
  function openLibrary() {
@@ -921,8 +951,8 @@ document.querySelectorAll('.panel-toggle').forEach(btn => {
921
951
  document.getElementById('fav-cancel').onclick = closeAddFavModal;
922
952
  document.getElementById('fav-save').onclick = commitFav;
923
953
  document.getElementById('lib-close').onclick = () => $libModal.classList.remove('show');
924
- $addFavModal.onclick = (e) => { if (e.target === $addFavModal) closeAddFavModal(); };
925
- $libModal.onclick = (e) => { if (e.target === $libModal) $libModal.classList.remove('show'); };
954
+ onBackdropDismiss($addFavModal, closeAddFavModal);
955
+ onBackdropDismiss($libModal, () => $libModal.classList.remove('show'));
926
956
 
927
957
  document.addEventListener('keydown', (e) => {
928
958
  if (e.key === 'Escape') {
@@ -951,6 +981,22 @@ function escapeHtml(s) {
951
981
  }
952
982
  function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }
953
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
+
954
1000
  /* ============= MENU ============= */
955
1001
 
956
1002
  const $menuBtn = document.getElementById('menu-btn');
@@ -1328,7 +1374,7 @@ const $integrationsModal = document.getElementById('integrations-modal');
1328
1374
  const $integrationsList = document.getElementById('integrations-list');
1329
1375
 
1330
1376
  document.getElementById('integrations-close').onclick = () => $integrationsModal.classList.remove('show');
1331
- $integrationsModal.onclick = (e) => { if (e.target === $integrationsModal) $integrationsModal.classList.remove('show'); };
1377
+ onBackdropDismiss($integrationsModal, () => $integrationsModal.classList.remove('show'));
1332
1378
 
1333
1379
  /* ============= KEYBOARD SHORTCUTS ============= */
1334
1380