@floless/app 0.56.0 → 0.57.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.
@@ -430,6 +430,14 @@ past a few hard facts, split it into a `references/` file and link it.
430
430
  Enforced by `npm run verify:flo` in CI. See "Every node's Description must be plain English".
431
431
  - Determinism is AWARE's: `<app>.lock` is the approved artifact; Run is gated on a fresh
432
432
  source-hash. Never let the UI run on drift.
433
+ - **Never edit a MAINTAINED app's `.flo` in place.** A maintained app is FloLess-published
434
+ (`publisher: floless`) and not forked — it tracks our update channel and its output is our
435
+ responsibility. To run it across projects with different data, **Duplicate** it (stays maintained,
436
+ keeps updates) and change only its inputs/contract. To change the *workflow logic* (add/edit a
437
+ node, the 2D/3D, an export), **Fork first** (`POST /api/app/:id/fork {accept:true}` — the user
438
+ accepts the liability terms), which takes it off the update channel and makes it theirs; only then
439
+ edit the `.flo`. An out-of-band logic edit to a maintained app trips the drift gate, which forces
440
+ the user to Restore the official version or Customize (fork) — shipped apps stay form-only.
433
441
  - Any change under `web/` requires a Playwright pass (visual + interaction), not just a check of
434
442
  the request body — see the project's standing rule.
435
443
  - When a real `aware`/bridge bug surfaces, file it on `aware-aeco/aware` (don't file your own
package/dist/web/app.css CHANGED
@@ -1237,6 +1237,25 @@
1237
1237
  .modal-field textarea { resize: vertical; min-height: 76px; line-height: 1.5; }
1238
1238
  .modal-field input:focus,
1239
1239
  .modal-field textarea:focus { border-color: var(--accent); }
1240
+ /* Consent checkbox (fork liability terms) — inline label + fully themed box (no native
1241
+ default leaks the dark baseline). Custom checkmark via clip-path, accent on check. */
1242
+ .modal-check { display: flex; gap: 9px; align-items: flex-start; cursor: pointer; font-size: 12.5px; line-height: 1.5; color: var(--text-muted); margin: 2px 0 4px; }
1243
+ .modal-check input[type="checkbox"] {
1244
+ appearance: none; -webkit-appearance: none; flex: none;
1245
+ width: 16px; height: 16px; margin: 1px 0 0;
1246
+ border: 1px solid var(--border-strong); border-radius: 4px; background: var(--surface-2);
1247
+ cursor: pointer; display: grid; place-content: center;
1248
+ transition: border-color 0.12s, background 0.12s;
1249
+ }
1250
+ .modal-check input[type="checkbox"]::before {
1251
+ content: ""; width: 10px; height: 10px; transform: scale(0); transition: transform 0.12s ease;
1252
+ background: var(--accent-bright);
1253
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
1254
+ }
1255
+ .modal-check input[type="checkbox"]:checked { border-color: var(--accent); background: var(--accent-soft); }
1256
+ .modal-check input[type="checkbox"]:checked::before { transform: scale(1); }
1257
+ .modal-check input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1258
+ .modal-check span { user-select: none; }
1240
1259
  .modal-categories { display: flex; gap: 5px; margin-top: 7px; flex-wrap: wrap; }
1241
1260
  .modal-categories button {
1242
1261
  background: var(--surface-2);
@@ -1759,6 +1778,14 @@
1759
1778
  .wf-update-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 4px; }
1760
1779
  .wf-update-pill:hover { border-color: var(--accent); color: var(--accent); }
1761
1780
  .wf-update-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1781
+ /* "yours" — a FORKED (user-owned, off-channel) workflow. Amber (--warn), NOT red: a forked app
1782
+ is an intentional customization, not a fault. Header pill (informational) + compact row badge. */
1783
+ .wf-forked-pill { display: none; border: 1px solid var(--warn); background: rgba(251, 191, 36, 0.08); color: var(--warn); font-size: 10px; padding: 1px 8px; border-radius: 999px; margin-left: 8px; line-height: 1.5; white-space: nowrap; cursor: default; }
1784
+ .wf-forked-pill:not([hidden]) { display: inline-flex; align-items: center; }
1785
+ .wf-fork-badge { font-size: 9px; font-weight: 600; letter-spacing: 0.06em; color: var(--warn); border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent); border-radius: 999px; padding: 1px 6px; line-height: 1.4; white-space: nowrap; flex-shrink: 0; align-self: center; }
1786
+ /* Restore (primary, accent) — re-installs the official version. Customize uses the default outline. */
1787
+ #restore-btn { color: var(--accent-bright); border-color: var(--accent-dim); }
1788
+ #restore-btn:hover { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
1762
1789
  /* Changelog list inside the update modal — themed scroll container (no native bar leaks). */
1763
1790
  .wf-update-changelog { max-height: 280px; overflow-y: auto; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
1764
1791
  .wf-update-changelog::-webkit-scrollbar { width: 6px; }
package/dist/web/aware.js CHANGED
@@ -27,6 +27,9 @@
27
27
  const $reportClose = document.getElementById('report-close');
28
28
  const $reportStop = document.getElementById('report-stop');
29
29
  const $stopRunBtn = document.getElementById('stop-run-btn');
30
+ const $restoreBtn = document.getElementById('restore-btn');
31
+ const $customizeBtn = document.getElementById('customize-btn');
32
+ const $wfForked = document.getElementById('wf-forked');
30
33
 
31
34
  // Contract editor — a dedicated full-window surface for an editable typed
32
35
  // contract (steel takeoff, …). Unlike the opaque report viewer, this is a
@@ -426,11 +429,14 @@
426
429
  ...app.nodes.map((n) => `**${escapeHtml(n.id)}** → \`${escapeHtml(n.agent || n.kind)}${n.command ? '/' + escapeHtml(n.command) : ''}\` · ${escapeHtml(n.mode)}`),
427
430
  ];
428
431
  if (app.notes.length) narration.push(`ℹ ${app.notes.length} compiler note${app.notes.length === 1 ? '' : 's'} — see the strip over the canvas.`);
432
+ const maintainedDrift = app.publisher === 'floless' && !app.forked && app.runState === 'drift';
429
433
  narration.push(app.runnable
430
434
  ? 'Lock is fresh (source-hash matches). **Run** is armed.'
431
- : app.runState === 'drift'
432
- ? 'The `.flo` changed since the last approve **Compile** to refresh the lock before Run.'
433
- : 'No lock yet — **Compile** to freeze the approved contract.');
435
+ : maintainedDrift
436
+ ? 'The workflow logic changed since it was last approved. **Restore** the official version, or **Customize** it to make it your own.'
437
+ : app.runState === 'drift'
438
+ ? 'The `.flo` changed since the last approve — **Compile** to refresh the lock before Run.'
439
+ : 'No lock yet — **Compile** to freeze the approved contract.');
434
440
 
435
441
  if (app.layout === 'dag') {
436
442
  PROMPTS[app.id] = { name: app.name, layout: 'dag', nodes: layoutDag(app.nodes, app.connections), connections: app.connections, userText, narration };
@@ -475,6 +481,21 @@
475
481
  $compileBtn.disabled = false;
476
482
  $compileBtn.textContent = needsCompile ? '⎙ Compile *' : '⎙ Compile';
477
483
 
484
+ // Maintained = a FloLess-published app the user hasn't forked (so it has an official version to
485
+ // restore + an update channel). Customize (fork) is offered for any maintained app; Restore +
486
+ // suppressing Compile fire only when a maintained app's LOGIC drifted — approving that via Compile
487
+ // would silently accept an edit to a shipped workflow, defeating "shipped apps are form-only".
488
+ const maintained = app.publisher === 'floless' && !app.forked;
489
+ const drifted = app.runState === 'drift';
490
+ if ($wfForked) $wfForked.hidden = !app.forked;
491
+ if ($customizeBtn) $customizeBtn.hidden = !maintained;
492
+ if ($restoreBtn) $restoreBtn.hidden = !(maintained && drifted);
493
+ // Suppress Compile for a maintained, drifted app: approving an out-of-band logic edit via
494
+ // Compile would defeat "shipped apps are form-only". Disable as well as hide, so no programmatic
495
+ // click / queued event / shortcut can fire it through the hidden-but-enabled element.
496
+ if (maintained && drifted) { $compileBtn.hidden = true; $compileBtn.disabled = true; }
497
+ else $compileBtn.hidden = false;
498
+
478
499
  // Bake: package the workflow as a reusable agent. Gated on the same runnable
479
500
  // state as Run (baking a stale/uncompiled app makes no sense — the lock is
480
501
  // what the server reads to mirror inputs). Disabled-with-tooltip (teach the
@@ -867,6 +888,7 @@
867
888
  const aid = escapeAttr(a.id);
868
889
  html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-baked="${a.baked ? '1' : ''}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
869
890
  + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
891
+ + (a.forked ? `<span class="wf-fork-badge" data-tip="You forked this workflow — it no longer receives FloLess updates. You own its output.">yours</span>` : '')
870
892
  + `<span class="wf-option-actions">`
871
893
  + `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
872
894
  + `<button type="button" class="wf-act act-dup" data-wf-act="duplicate" data-id="${aid}" tabindex="-1" data-tip="Duplicate" aria-label="Duplicate ${aid}">⧉</button>`
@@ -1127,6 +1149,64 @@
1127
1149
  }
1128
1150
  }
1129
1151
 
1152
+ // ── Fork / Restore: the maintained ⇄ owned boundary ──────────────────────────
1153
+ // FORK crosses a maintained app to user-owned: it requires explicit liability acceptance (a ticked
1154
+ // checkbox — informed consent for structural-steel output) and takes the app off the update channel.
1155
+ // The .flo is NOT edited here; the user changes the workflow afterwards (via the terminal AI). RESTORE
1156
+ // re-installs the official version over a forked/edited app and returns it to maintained.
1157
+ async function forkWorkflow(id) {
1158
+ closeCombo(false);
1159
+ const res = await formModal({
1160
+ title: 'Customize this workflow',
1161
+ sub: 'Forking lets you change the workflow logic — but it takes it off the FloLess update channel. If you only need a different project’s inputs, use Duplicate (⧉) instead — duplicates stay maintained.',
1162
+ fields: [{
1163
+ type: 'checkbox',
1164
+ name: 'accept',
1165
+ label: 'I understand this is structural-steel software. I am responsible for verifying that any changes I make produce correct output before using the results on a real project.',
1166
+ required: true,
1167
+ value: false,
1168
+ }],
1169
+ okLabel: 'Fork & customize',
1170
+ });
1171
+ if (!res || res.accept !== true) return; // cancelled, or (defensive) terms not ticked
1172
+ showToast('Forking…', 'ok');
1173
+ try {
1174
+ await api(`/api/app/${encodeURIComponent(id)}/fork`, { method: 'POST', body: JSON.stringify({ accept: true }) });
1175
+ await loadApps();
1176
+ if (currentId === id) await loadApp(id);
1177
+ await refreshWorkflowUpdates(); // it just left the channel — drop any pending update pill
1178
+ showToast('Forked — this workflow is now yours to edit. Run ⎙ Compile when you’re ready.', 'ok');
1179
+ } catch (e) {
1180
+ console.error(e);
1181
+ showToast(e.message || 'Fork failed', 'warn');
1182
+ }
1183
+ }
1184
+
1185
+ async function restoreWorkflow(id) {
1186
+ closeCombo(false);
1187
+ const ok = await formModal({
1188
+ title: 'Restore the official version?',
1189
+ sub: 'Re-installs the official workflow and discards any changes made to its logic. Your saved inputs are kept.',
1190
+ fields: [],
1191
+ okLabel: 'Restore',
1192
+ });
1193
+ if (!ok) return;
1194
+ showToast('Restoring…', 'ok');
1195
+ try {
1196
+ await api(`/api/app/${encodeURIComponent(id)}/restore`, { method: 'POST', body: JSON.stringify({}) });
1197
+ await loadApps();
1198
+ if (currentId === id) await loadApp(id);
1199
+ await refreshWorkflowUpdates(); // back on the channel — re-offer any pending update
1200
+ showToast('Restored to the official version.', 'ok');
1201
+ } catch (e) {
1202
+ console.error(e);
1203
+ showToast(e.message || 'Restore failed', 'warn');
1204
+ }
1205
+ }
1206
+
1207
+ if ($customizeBtn) $customizeBtn.onclick = () => { if (currentId) forkWorkflow(currentId); };
1208
+ if ($restoreBtn) $restoreBtn.onclick = () => { if (currentId) restoreWorkflow(currentId); };
1209
+
1130
1210
  // ── app inputs + HTML Viewer ────────────────────────────────────────────────
1131
1211
 
1132
1212
  // The id of the report-viewer node — the terminal node a user double-clicks to
@@ -2165,6 +2245,11 @@
2165
2245
  const id = `fm-${f.name}`;
2166
2246
  const val = f.value != null ? String(f.value) : '';
2167
2247
  const ph = escapeHtml(f.placeholder || '');
2248
+ // A consent checkbox renders inline (label beside the box, not above) — used for the
2249
+ // fork liability terms. `required: true` gates the OK button until it's ticked.
2250
+ if (f.type === 'checkbox') {
2251
+ return `<label class="modal-check" for="${id}"><input type="checkbox" id="${id}" data-fm-check="${escapeHtml(f.name)}"${f.value ? ' checked' : ''}><span>${escapeHtml(f.label || f.name)}</span></label>`;
2252
+ }
2168
2253
  let ctl;
2169
2254
  if (f.type === 'images') {
2170
2255
  ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
@@ -2308,9 +2393,20 @@
2308
2393
  const file = fileItems[0].getAsFile();
2309
2394
  if (entry && file) entry.setFile(file);
2310
2395
  };
2396
+ // Required consent checkboxes (e.g. the fork liability terms) disable OK until ticked.
2397
+ const requiredChecks = Array.from($body.querySelectorAll('[data-fm-check]')).filter((el) => {
2398
+ const f = fields.find((x) => x.name === el.dataset.fmCheck);
2399
+ return f && f.required;
2400
+ });
2401
+ if (requiredChecks.length) {
2402
+ const syncOkGate = () => { $ok.disabled = requiredChecks.some((el) => !el.checked); };
2403
+ requiredChecks.forEach((el) => { el.onchange = syncOkGate; });
2404
+ syncOkGate();
2405
+ }
2311
2406
  const collect = () => {
2312
2407
  const out = {};
2313
2408
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
2409
+ $body.querySelectorAll('[data-fm-check]').forEach((el) => { out[el.dataset.fmCheck] = el.checked; });
2314
2410
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
2315
2411
  fileStates.forEach((entry, name) => {
2316
2412
  out[name] = entry.st.mode === 'empty' ? '' : entry.st.value;
@@ -2323,7 +2419,7 @@
2323
2419
  _formModalOpen = false;
2324
2420
  hideModal($formModal);
2325
2421
  $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
2326
- $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
2422
+ $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); $ok.disabled = false; // reset chrome for the next caller
2327
2423
  resolve(result);
2328
2424
  };
2329
2425
  $ok.onclick = () => done(collect());
@@ -2334,7 +2430,7 @@
2334
2430
  // commits only for a normal form (never a danger confirm; never inside a textarea).
2335
2431
  $formModal.onkeydown = (e) => {
2336
2432
  if (e.key === 'Escape') { e.preventDefault(); done(null); }
2337
- else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
2433
+ else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA' && !$ok.disabled) { e.preventDefault(); done(collect()); }
2338
2434
  };
2339
2435
  // Setup succeeded and the modal is shown — mark it open now. Setting it here (not
2340
2436
  // before the synchronous setup above) means a setup throw can't latch the guard for
@@ -2441,7 +2537,7 @@
2441
2537
  $promptSel.onchange = () => { loadApp($promptSel.value).catch(reportErr); };
2442
2538
 
2443
2539
  $compileBtn.onclick = async () => {
2444
- if (!currentId || $compileBtn.disabled) return;
2540
+ if (!currentId || $compileBtn.disabled || $compileBtn.hidden) return; // hidden = suppressed for a maintained drift
2445
2541
  const id = currentId;
2446
2542
  $compileBtn.disabled = true;
2447
2543
  const prev = $compileBtn.textContent;
@@ -55,6 +55,9 @@
55
55
  </div>
56
56
  <select id="prompt-select" hidden></select>
57
57
  <button id="wf-update" class="wf-update-pill" type="button" hidden aria-haspopup="dialog"></button>
58
+ <!-- Shown only when the OPEN workflow is forked (user-owned, off the update channel). Purely
59
+ informational (the badge of ownership); the picker row carries the same "yours" cue. -->
60
+ <span id="wf-forked" class="wf-forked-pill" hidden role="status" data-tip="You forked this workflow — it no longer receives FloLess updates. You are responsible for verifying its output.">yours · off updates</span>
58
61
  <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
59
62
  (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
60
63
  <span class="ctl-sep" aria-hidden="true"></span>
@@ -66,6 +69,12 @@
66
69
  <span class="guide-beacon-label">Start here</span>
67
70
  </button>
68
71
  <span class="run-state" id="run-state" role="status" aria-live="polite"></span>
72
+ <!-- Restore + Customize: shown for a MAINTAINED (publisher:floless, non-forked) app. Restore
73
+ appears only when its logic drifted (re-installs the official version); Customize (fork) is
74
+ always available for a maintained app. When shown on drift they replace Compile, so a
75
+ shipped app's logic edit can't be silently approved (form-only). -->
76
+ <button id="restore-btn" type="button" hidden data-tip="Restore the official workflow — undoes any out-of-band edits and re-arms Run">↺ Restore</button>
77
+ <button id="customize-btn" type="button" hidden data-tip="Fork this workflow to edit it yourself — takes it off the FloLess update channel">Customize…</button>
69
78
  <button id="compile-btn" data-tip="Compile + approve → freeze the .lock">⎙ Compile</button>
70
79
  <button id="sim-btn" data-tip="Simulate: stub every node from its output schema — no live host is contacted. Validates the workflow's composition end-to-end, even when the real agents aren't connected yet.">Simulate</button>
71
80
  <button id="run-btn" data-tip="Run the workflow for real against the live host (uses the inputs; renders the report node in the HTML Viewer)">▶ Run workflow</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.56.0",
3
+ "version": "0.57.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": {