@floless/app 0.16.2 → 0.17.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.
@@ -0,0 +1,72 @@
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
+ ## The loop
26
+
27
+ 1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
28
+ `type: "rebake"`. It carries `appId`, the `inputName` (the baked input, e.g. `drawing`),
29
+ an `instruction`, and `snapshots: ["<abs path to the new image>"]`. (The user may also just
30
+ ask in prose with an image attached — same procedure, skip the request lookup.)
31
+ 2. **Read the new drawing.** Open the image at `snapshots[0]` (a real PNG/JPEG/WebP on disk).
32
+ Read it with your own vision — this is the compose-time extraction.
33
+ 3. **Re-extract the same schema the app already bakes.** Open the app source +
34
+ `config.yaml` under `~/.aware/apps/<appId>/` (and the editable copy under `demos/<appId>/`
35
+ if present). Identify the baked literals (e.g. `config.schedule-rows`). Re-extract **the same
36
+ shape** from the new drawing — same fields, same types — so the deterministic nodes keep
37
+ working. Do NOT invent values the drawing doesn't state; leave the app's documented
38
+ sentinel/default for anything absent (mirror how it was originally baked).
39
+ 4. **Rewrite ONLY the baked config values.** Edit `~/.aware/apps/<appId>/config.yaml`
40
+ (and the `demos/<appId>/config.yaml` editable copy) — replace the baked literals with the
41
+ re-extracted ones. **Do not change node logic, the `.flo`, or the schema** — only the values.
42
+ 5. **Recompile.** `aware app compile ~/.aware/apps/<appId>` → writes a fresh `<appId>.lock`.
43
+ The source-hash changes, so floless's Run gate disarms until approval. Copy the lock back to
44
+ `demos/<appId>/` if you keep an editable copy.
45
+ 6. **Hand the approval to the user.** Tell them what changed (e.g. "re-baked 7 schedule rows
46
+ from the new drawing — review the diff in Inspect → Code, then Compile/Approve, then ▶ Run").
47
+ The human eyeballing the re-extraction before any run **is the safety gate** (it replaces the
48
+ `approve:` block AWARE's bake pattern recommends). Never auto-run.
49
+ 7. **Clear the request.** `DELETE http://localhost:<port>/api/requests/<id>` once done.
50
+
51
+ ## Guardrails
52
+
53
+ - **Compose-time only.** Never add a runtime node that reads the drawing — `aware app validate`
54
+ rejects it, and it would break determinism. The whole point of B3 is to keep the run
55
+ deterministic by re-baking up front.
56
+ - **Values, not logic.** Re-bake only `config.yaml` literals. If the new drawing needs a
57
+ *different shape* (new columns, a different schema), that's a workflow change, not a re-bake —
58
+ tell the user and use `floless-app-workflows` instead.
59
+ - **Thin-UI contract holds.** The browser only recorded intent + the image; the brain is you,
60
+ in the terminal. The UI never re-bakes and never reads the drawing.
61
+ - **Resolve the port** from the running floless.app (the `/api` base the user's workspace is on)
62
+ the same way the other floless-app skills do.
63
+
64
+ ## Relationship to the other strategies
65
+
66
+ - **A (bake)** is the first-time version of this (read at compose, bake). B3 is "do it again for a
67
+ new drawing." Same act, re-triggered by a swap.
68
+ - **B1 (parse)** is for deterministically-parseable inputs that DON'T need re-baking — there the
69
+ image is a true runtime input read by an `exec` node; the user swaps and runs with no re-bake.
70
+ - **B2 (vision)** — a curated runtime `vision.extract` agent — is the future zero-friction
71
+ replacement for B3 once AWARE ships it (RFC `aware-aeco/aware#223`). Until then, B3 is the
72
+ swap-and-(almost)-run answer for vision 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
@@ -574,6 +574,16 @@
574
574
  word-break: break-word;
575
575
  }
576
576
  .agent-card .blurb { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; margin-bottom: 10px; }
577
+ /* B2: the one node that calls a model at run time (vision.extract). A subtle accent
578
+ pill so the approver sees the non-deterministic-on-first-input node honestly. */
579
+ .agent-card .rt-model-badge {
580
+ display: inline-flex; align-items: center; gap: 4px;
581
+ margin: 0 0 8px; padding: 2px 8px;
582
+ font: 600 10px/1.4 var(--ui); letter-spacing: 0.01em;
583
+ color: var(--accent); background: var(--accent-soft);
584
+ border: 1px solid var(--accent-dim); border-radius: 999px;
585
+ cursor: help;
586
+ }
577
587
  .agent-card .footer-row {
578
588
  display: flex;
579
589
  justify-content: space-between;
@@ -2366,6 +2376,23 @@ body {
2366
2376
  .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
2377
  .fm-thumb:hover .fm-thumb-del, .fm-thumb-del:focus-visible { opacity: 1; }
2368
2378
  .fm-thumb-del:hover { color: var(--err); border-color: var(--err); }
2379
+ /* ===== Visual Input: single image/PDF file field (inputs dialog) ===== */
2380
+ .fm-file { position: relative; }
2381
+ .fm-file .fm-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
2382
+ .fm-drop .fm-drop-hint { display: block; margin-top: 3px; font-size: 10px; color: var(--text-dim); }
2383
+ .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; }
2384
+ .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; }
2385
+ .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; }
2386
+ .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; }
2387
+ .fm-file-actions { display: flex; gap: 5px; flex-shrink: 0; }
2388
+ .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); }
2389
+ .fm-file-replace:hover { color: var(--text); border-color: var(--accent-dim); }
2390
+ .fm-file-clear:hover { color: var(--err); border-color: var(--err); }
2391
+ /* Visual Input chip on the canvas input node */
2392
+ .agent-card .node-inputs .ni-pair-file { align-items: center; }
2393
+ .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; }
2394
+ .agent-card .node-inputs .ni-file-name { max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; }
2395
+ .agent-card .node-inputs .ni-not-set { color: var(--text-dim); font-style: italic; font-weight: 400; }
2369
2396
  /* ===== Requests: snapshot thumbnails ===== */
2370
2397
  .req-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
2371
2398
  .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
@@ -346,6 +346,7 @@ function cardEl(id, ports) {
346
346
  </div>
347
347
  <div class="title">${a.title}</div>
348
348
  <div class="subtitle">${a.subtitle}</div>
349
+ ${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
350
  <div class="blurb">${a.blurb}</div>
350
351
  <div class="footer-row">
351
352
  <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>
package/dist/web/aware.js CHANGED
@@ -284,6 +284,7 @@
284
284
  for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
285
285
  return {
286
286
  _mode: n.mode,
287
+ _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
287
288
  icon: iconFor(n.agent),
288
289
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
289
290
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
@@ -780,13 +781,27 @@
780
781
  // every markSpecialNodes — which openInputsDialog calls right after a change.
781
782
  function addNodeInputs(card) {
782
783
  const vals = currentInputs();
783
- const keys = Object.keys(vals);
784
+ const app = currentId && apps.get(currentId);
785
+ const declared = app && Array.isArray(app.inputs) ? app.inputs : [];
786
+ const byName = new Map(declared.map((i) => [i.name, i]));
787
+ const isVisual = (inp) => !!inp && (inp.widget === 'file' || inp.type === 'image' || inp.type === 'file');
788
+ const baseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
789
+ // Show every SET value, plus any declared visual input (so its "not set" /
790
+ // attach affordance is visible on the node before the first pick).
791
+ const keys = [...new Set([...Object.keys(vals), ...declared.filter(isVisual).map((i) => i.name)])];
784
792
  if (!keys.length) return;
785
793
  const wrap = document.createElement('div');
786
794
  wrap.className = 'node-inputs';
787
- wrap.innerHTML = keys
788
- .map((k) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`)
789
- .join('');
795
+ wrap.innerHTML = keys.map((k) => {
796
+ const inp = byName.get(k);
797
+ if (isVisual(inp)) {
798
+ const v = String(vals[k] || '');
799
+ if (!v) return `<span class="ni-pair ni-pair-file ni-pair-file-empty"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val ni-not-set">not set</span></span>`;
800
+ const glyph = /\.pdf$/i.test(v) ? 'pdf' : 'img';
801
+ return `<span class="ni-pair ni-pair-file"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-file-glyph">${glyph}</span><span class="ni-val ni-file-name" title="${escapeAttr(v)}">${escapeHtml(baseName(v))}</span></span>`;
802
+ }
803
+ return `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`;
804
+ }).join('');
790
805
  card.appendChild(wrap);
791
806
  }
792
807
 
@@ -797,18 +812,57 @@
797
812
  function markSpecialNodes() {
798
813
  const rid = reportNodeId();
799
814
  const iid = inputNodeId();
800
- document.querySelectorAll('.agent-card').forEach((card) => {
815
+ // B3: a baked-visual app (read-strategy: bake) has no runtime input node, so the
816
+ // "Re-read & re-bake ▸" affordance lands on the entry (first) node card.
817
+ const app = currentId && apps.get(currentId);
818
+ const rebakeInput = app && app.rebakeInput ? app.rebakeInput : null;
819
+ const cards = document.querySelectorAll('.agent-card');
820
+ const firstId = cards.length ? cards[0].dataset.agentId : null;
821
+ cards.forEach((card) => {
801
822
  const id = card.dataset.agentId;
802
823
  const isReport = !!rid && id === rid;
803
824
  const isInput = !!iid && id === iid;
825
+ const isRebake = !!rebakeInput && id === firstId;
804
826
  card.classList.toggle('report-node', isReport);
805
827
  card.classList.toggle('input-node', isInput);
828
+ card.classList.toggle('rebake-node', isRebake);
806
829
  // clear anything we injected last render, so re-renders never stack
807
830
  card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
808
831
  if (isInput) addNodeInputs(card); // current values, above the button
809
832
  if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
810
833
  if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
834
+ if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
835
+ });
836
+ }
837
+
838
+ // B3 "swap re-bakes": pick a new source drawing for a baked app and queue a
839
+ // re-bake request. Thin-UI — the host AI (via the floless-app-rebake skill)
840
+ // does the actual re-read + re-bake at compose time; the browser only records
841
+ // intent + the new image. Re-uses the SP1 file field + the request relay.
842
+ async function openRebakeDialog() {
843
+ const app = currentId && apps.get(currentId);
844
+ if (!app || !app.rebakeInput) return;
845
+ const name = app.rebakeInput;
846
+ const res = await formModal({
847
+ title: `Re-read & re-bake · ${app.displayName}`,
848
+ sub: 'Swap the source drawing. Your terminal AI will re-read it, re-bake the values into config, then ask you to approve.',
849
+ fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['png', 'jpg', 'webp'] }],
850
+ okLabel: 'Queue re-bake',
811
851
  });
852
+ if (!res) return;
853
+ const dataUrl = res[name];
854
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
855
+ try {
856
+ const r = await fetch('/api/rebake', {
857
+ method: 'POST',
858
+ headers: { 'content-type': 'application/json' },
859
+ body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
860
+ });
861
+ const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
862
+ if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
863
+ showToast('Queued — your terminal AI will re-read & re-bake, then ask you to approve.', 'ok');
864
+ appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, re-bake the config literals, and ask you to approve the new lock.`);
865
+ } catch { showToast('could not queue re-bake', 'err'); }
812
866
  }
813
867
 
814
868
  // Per-app declared-input values, set via the input node's double-click dialog.
@@ -932,25 +986,54 @@
932
986
  const app = currentId && apps.get(currentId);
933
987
  if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return;
934
988
  const cur = currentInputs();
935
- const fields = app.inputs.map((inp) => ({
936
- name: inp.name,
937
- label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
938
- type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
939
- value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
940
- }));
989
+ const isVisual = (inp) => inp.widget === 'file' || inp.type === 'image' || inp.type === 'file';
990
+ const visualNames = new Set(app.inputs.filter(isVisual).map((i) => i.name));
991
+ const fields = app.inputs.map((inp) => isVisual(inp)
992
+ ? {
993
+ name: inp.name,
994
+ label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
995
+ type: 'file',
996
+ accept: inp.accept,
997
+ value: cur[inp.name] != null ? cur[inp.name] : '',
998
+ }
999
+ : {
1000
+ name: inp.name,
1001
+ label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
1002
+ type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
1003
+ value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
1004
+ });
941
1005
  const res = await formModal({ title: `Inputs · ${app.displayName}`, sub: 'Set the values this run uses, then ▶ Run workflow.', fields, okLabel: 'Set inputs' });
942
1006
  if (!res) return;
943
1007
  const store = {};
944
- app.inputs.forEach((inp) => {
1008
+ for (const inp of app.inputs) {
1009
+ if (isVisual(inp)) {
1010
+ const v = res[inp.name];
1011
+ if (typeof v === 'string' && v.startsWith('data:')) {
1012
+ // Fresh pick/paste → persist it to the store; the input VALUE is the path.
1013
+ try {
1014
+ const r = await fetch('/api/visual-input', {
1015
+ method: 'POST',
1016
+ headers: { 'content-type': 'application/json' },
1017
+ body: JSON.stringify({ appId: app.id, name: inp.name, dataUrl: v }),
1018
+ });
1019
+ const up = await r.json().catch(() => ({ ok: false, error: `upload failed (${r.status})` }));
1020
+ if (!up || !up.ok) { showToast((up && up.error) || 'could not store the file', 'err'); return; }
1021
+ store[inp.name] = up.path;
1022
+ } catch { showToast('could not upload the file', 'err'); return; }
1023
+ } else if (typeof v === 'string' && v) {
1024
+ store[inp.name] = v; // unchanged existing path
1025
+ } // cleared/empty → omit (no value)
1026
+ continue;
1027
+ }
945
1028
  let v = String(res[inp.name] ?? '').trim();
946
- if (v === '') { if (inp.default != null) v = String(inp.default); else return; }
947
- if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; return; } }
1029
+ if (v === '') { if (inp.default != null) v = String(inp.default); else continue; }
1030
+ if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; continue; } }
948
1031
  store[inp.name] = v;
949
- });
1032
+ }
950
1033
  appInputValues.set(currentId, store);
951
1034
  markSpecialNodes();
952
1035
  refreshDirtyIndicator(); // values changed → reflect unsaved state in the header/menu
953
- const badge = Object.entries(store).map(([k, v]) => `${k}=${v}`).join(' · ');
1036
+ const badge = Object.entries(store).map(([k, v]) => `${k}=${visualNames.has(k) ? String(v).split(/[\\/]/).pop() : v}`).join(' · ');
954
1037
  appendNarration(`Inputs set — <strong>${escapeHtml(badge)}</strong>. Run with <strong>▶ Run workflow</strong>.`);
955
1038
  showToast('Inputs set: ' + badge, 'ok');
956
1039
  }
@@ -1286,6 +1369,16 @@
1286
1369
  <div class="fm-drop" role="button" tabindex="0" aria-label="Paste a screenshot or click to attach">Paste a screenshot (Ctrl+V) or click to add<input type="file" accept="image/*" multiple class="fm-file-input" tabindex="-1" aria-hidden="true"></div>
1287
1370
  <div class="fm-thumbs" hidden></div>
1288
1371
  </div>`;
1372
+ } else if (f.type === 'file') {
1373
+ // Single image/PDF Visual Input. Reuses the .fm-drop visual language; the
1374
+ // hidden file input is a SIBLING of the drop (not a child) so render() can
1375
+ // freely rewrite the drop's innerHTML between empty/has-file without losing it.
1376
+ const accepts = (f.accept && f.accept.length) ? f.accept : ['png', 'jpg', 'webp', 'pdf'];
1377
+ const acceptAttr = accepts.map((e) => '.' + String(e).replace(/^\./, '')).join(',');
1378
+ ctl = `<div class="fm-file" data-fm-file-box="${escapeAttr(f.name)}" data-accept-hint="${escapeAttr(accepts.join(', ').toUpperCase())}" data-init-value="${escapeAttr(val)}">
1379
+ <div class="fm-drop" role="button" tabindex="0" aria-label="Attach an image or PDF"></div>
1380
+ <input type="file" accept="${escapeAttr(acceptAttr)}" class="fm-file-input" tabindex="-1" aria-hidden="true">
1381
+ </div>`;
1289
1382
  } else {
1290
1383
  ctl = f.multiline
1291
1384
  ? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
@@ -1333,32 +1426,81 @@
1333
1426
  fileInput.value = '';
1334
1427
  };
1335
1428
  });
1336
- // ── paste support: paste an image into the modal → first images field ──────
1429
+ // ── file-field setup (single image/PDF Visual Input) ───────────────────────
1430
+ // st.mode: 'empty' | 'path' (a previously-set on-disk path, passed through
1431
+ // unchanged) | 'dataurl' (a fresh pick/paste the caller uploads to the store).
1432
+ const fileStates = new Map();
1433
+ const fmBaseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
1434
+ const fmExtOf = (p) => { const m = /\.([a-z0-9]+)$/i.exec(String(p || '')); return m ? m[1].toLowerCase() : ''; };
1435
+ $body.querySelectorAll('.fm-file').forEach((box) => {
1436
+ const name = box.dataset.fmFileBox;
1437
+ const hint = box.dataset.acceptHint || 'PNG, JPG, PDF';
1438
+ const drop = box.querySelector('.fm-drop');
1439
+ const fileInput = box.querySelector('.fm-file-input');
1440
+ const init = box.dataset.initValue || '';
1441
+ const st = init
1442
+ ? { mode: 'path', value: init, name: fmBaseName(init), ext: fmExtOf(init) }
1443
+ : { mode: 'empty', value: '', name: '', ext: '' };
1444
+ const render = () => {
1445
+ const has = st.mode !== 'empty';
1446
+ drop.classList.toggle('has-file', has);
1447
+ if (!has) {
1448
+ drop.innerHTML = `Paste (Ctrl+V) or click to attach<span class="fm-drop-hint">${escapeHtml(hint)}</span>`;
1449
+ return;
1450
+ }
1451
+ const glyph = st.ext === 'pdf' ? 'pdf' : 'img';
1452
+ drop.innerHTML = `<span class="fm-file-glyph">${glyph}</span><span class="fm-file-name" title="${escapeAttr(st.value)}">${escapeHtml(st.name)}</span><span class="fm-file-actions"><button type="button" class="fm-file-replace">Replace</button><button type="button" class="fm-file-clear">Clear</button></span>`;
1453
+ drop.querySelector('.fm-file-replace').onclick = (e) => { e.stopPropagation(); fileInput.click(); };
1454
+ drop.querySelector('.fm-file-clear').onclick = (e) => { e.stopPropagation(); st.mode = 'empty'; st.value = ''; st.name = ''; st.ext = ''; render(); };
1455
+ };
1456
+ const setFile = (file) => {
1457
+ if (!file) return;
1458
+ const ext = fmExtOf(file.name) || (file.type === 'application/pdf' ? 'pdf' : '');
1459
+ const reader = new FileReader();
1460
+ reader.onload = () => { st.mode = 'dataurl'; st.value = reader.result; st.name = file.name || ('pasted.' + (ext || 'png')); st.ext = ext; render(); };
1461
+ reader.onerror = () => showToast('could not read the file', 'err');
1462
+ reader.readAsDataURL(file);
1463
+ };
1464
+ drop.onclick = () => { if (st.mode === 'empty') fileInput.click(); };
1465
+ drop.onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && st.mode === 'empty') { e.preventDefault(); fileInput.click(); } };
1466
+ fileInput.onchange = () => { const f0 = (fileInput.files || [])[0]; if (f0) setFile(f0); fileInput.value = ''; };
1467
+ fileStates.set(name, { st, setFile });
1468
+ render();
1469
+ });
1470
+ // ── paste support: an image into the modal → the images field, else the file field ──
1337
1471
  $body.onpaste = (e) => {
1338
- const box = $body.querySelector('.fm-images');
1339
- if (!box) return;
1340
1472
  const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
1341
1473
  const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
1342
- if (!imgs.length) return;
1474
+ const box = $body.querySelector('.fm-images');
1475
+ if (box) {
1476
+ if (!imgs.length) return;
1477
+ e.preventDefault();
1478
+ const entry = imageStates.get(box.dataset.fmImages);
1479
+ if (!entry) return;
1480
+ const { state, renderThumbs } = entry;
1481
+ const room = Math.max(0, 8 - state.length);
1482
+ if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
1483
+ imgs.slice(0, room).forEach((it) => {
1484
+ const file = it.getAsFile();
1485
+ if (!file) return;
1486
+ const reader = new FileReader();
1487
+ reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
1488
+ reader.readAsDataURL(file);
1489
+ });
1490
+ return;
1491
+ }
1492
+ const fileBox = $body.querySelector('.fm-file');
1493
+ if (!fileBox || !imgs.length) return;
1343
1494
  e.preventDefault();
1344
- const name = box.dataset.fmImages;
1345
- const entry = imageStates.get(name);
1346
- if (!entry) return;
1347
- const { state, renderThumbs } = entry;
1348
- const room = Math.max(0, 8 - state.length);
1349
- if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
1350
- imgs.slice(0, room).forEach((it) => {
1351
- const file = it.getAsFile();
1352
- if (!file) return;
1353
- const reader = new FileReader();
1354
- reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
1355
- reader.readAsDataURL(file);
1356
- });
1495
+ const entry = fileStates.get(fileBox.dataset.fmFileBox);
1496
+ const file = imgs[0].getAsFile();
1497
+ if (entry && file) entry.setFile(file);
1357
1498
  };
1358
1499
  const collect = () => {
1359
1500
  const out = {};
1360
1501
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
1361
1502
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
1503
+ fileStates.forEach((entry, name) => { out[name] = entry.st.mode === 'empty' ? '' : entry.st.value; });
1362
1504
  return out;
1363
1505
  };
1364
1506
  const done = (result) => {
@@ -3036,6 +3178,13 @@
3036
3178
  const scope = req.panelId ? ` (panel "${req.panelId}")` : '';
3037
3179
  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
3180
  }
3181
+ if (req.type === 'rebake') {
3182
+ const where = req.inputName ? ` (input "${req.inputName}")` : '';
3183
+ const snaps = req.snapshots && req.snapshots.length
3184
+ ? `\nNew drawing${req.snapshots.length > 1 ? 's' : ''} (read for the re-extraction): ${req.snapshots.join(', ')}`
3185
+ : '';
3186
+ return `In floless app "${req.appId}", re-read & re-bake${where} per the floless-app-rebake skill: ${req.instruction}${snaps}`;
3187
+ }
3039
3188
  return '';
3040
3189
  }
3041
3190
 
@@ -3114,9 +3263,13 @@
3114
3263
  return;
3115
3264
  }
3116
3265
  $list.innerHTML = pendingRequests.map((r) => {
3117
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : 'tweak';
3118
- const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' ? 'req-type req-type-tweak' : 'req-type';
3119
- const target = r.type === 'tweak' && r.nodeId ? ` · node <code>${escapeHtml(r.nodeId)}</code>` : '';
3266
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : 'tweak';
3267
+ const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
3268
+ const target = r.type === 'tweak' && r.nodeId
3269
+ ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
3270
+ : r.type === 'rebake' && r.inputName
3271
+ ? ` · input <code>${escapeHtml(r.inputName)}</code>`
3272
+ : '';
3120
3273
  const when = r.createdAt ? new Date(r.createdAt) : null;
3121
3274
  const time = when && !isNaN(when) ? `<span class="req-time">${escapeHtml(nowStamp(when))}</span>` : '';
3122
3275
  return `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {