@floless/app 0.29.0 → 0.31.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.
@@ -51016,6 +51016,8 @@ function readApp(id) {
51016
51016
  kind: locked && typeof locked.kind === "string" ? locked.kind : "agent",
51017
51017
  mode: lockMode === "write" || lockMode === "read" ? lockMode : "unknown",
51018
51018
  runtimeModel: !!locked && locked["runtime-model"] === true,
51019
+ // `frozen:` lives on the SOURCE node (the user froze it); present (non-null) = frozen.
51020
+ frozen: n.frozen != null,
51019
51021
  inputs: asRecord(locked?.inputs),
51020
51022
  config: asRecord(n.config),
51021
51023
  notes: notes2,
@@ -51627,6 +51629,10 @@ function assertId(id) {
51627
51629
  if (!APP_ID.test(id)) throw new AwareError(`invalid app id: ${JSON.stringify(id)}`);
51628
51630
  return id;
51629
51631
  }
51632
+ function assertNodeId(id) {
51633
+ if (!APP_ID.test(id)) throw new AwareError(`invalid node id: ${JSON.stringify(id)}`);
51634
+ return id;
51635
+ }
51630
51636
  var SOURCE_EXT = /\.(flo|app|flow|aware)$/i;
51631
51637
  function assertSourcePath(p) {
51632
51638
  if (!SOURCE_EXT.test(p)) throw new AwareError(`source must be a .flo/.app file: ${JSON.stringify(p)}`);
@@ -52008,6 +52014,39 @@ var aware = {
52008
52014
  }
52009
52015
  return { compiled: /lock refreshed/i.test(stdout), output: stdout.trim() };
52010
52016
  },
52017
+ /**
52018
+ * Freeze a node (`aware app freeze <app> <node>`, CLI ≥ 0.77, aware-aeco#261). Pins the
52019
+ * node's LAST run output into the source as a `frozen:` block so Run skips the agent
52020
+ * (replays the pinned value, never re-runs it) and recompiles the lock. Fails when the
52021
+ * node has never produced output (nothing to pin) — the route relays that as "run it
52022
+ * once first". Throws AwareUnsupportedError on a pre-0.77 CLI so the route can say "update
52023
+ * AWARE" instead of treating an old CLI as a hard fault (mirrors rename/duplicate).
52024
+ */
52025
+ async freeze(appId, nodeId) {
52026
+ const { code, stdout, stderr } = await runRaw(["app", "freeze", assertId(appId), assertNodeId(nodeId)]);
52027
+ if (code !== 0) {
52028
+ if (isInvokeUnsupported({ stdout, stderr })) {
52029
+ throw new AwareUnsupportedError("aware CLI does not support `app freeze` (needs \u2265 0.77) \u2014 update AWARE", { stdout, stderr });
52030
+ }
52031
+ throw new AwareError(`aware app freeze ${nodeId} failed (exit ${code})`, { stdout, stderr, code });
52032
+ }
52033
+ return { output: stdout.trim() };
52034
+ },
52035
+ /**
52036
+ * Unfreeze a node (`aware app unfreeze <app> <node>`, CLI ≥ 0.77). Removes the node's
52037
+ * `frozen:` block so it runs normally again and recompiles. Same unsupported-degradation
52038
+ * contract as freeze.
52039
+ */
52040
+ async unfreeze(appId, nodeId) {
52041
+ const { code, stdout, stderr } = await runRaw(["app", "unfreeze", assertId(appId), assertNodeId(nodeId)]);
52042
+ if (code !== 0) {
52043
+ if (isInvokeUnsupported({ stdout, stderr })) {
52044
+ throw new AwareUnsupportedError("aware CLI does not support `app unfreeze` (needs \u2265 0.77) \u2014 update AWARE", { stdout, stderr });
52045
+ }
52046
+ throw new AwareError(`aware app unfreeze ${nodeId} failed (exit ${code})`, { stdout, stderr, code });
52047
+ }
52048
+ return { output: stdout.trim() };
52049
+ },
52011
52050
  /**
52012
52051
  * Install a first-party agent by registry id (`aware agent install <id>`). The
52013
52052
  * registry install pulls a large tarball (~200s observed) — far over runRaw's 60s
@@ -52796,7 +52835,7 @@ function appVersion() {
52796
52835
  return resolveVersion({
52797
52836
  isSea: isSea2(),
52798
52837
  sqVersionXml: readSqVersionXml(),
52799
- define: true ? "0.29.0" : void 0,
52838
+ define: true ? "0.31.0" : void 0,
52800
52839
  pkgVersion: readPkgVersion()
52801
52840
  });
52802
52841
  }
@@ -52806,7 +52845,7 @@ function resolveChannel(s) {
52806
52845
  return "dev";
52807
52846
  }
52808
52847
  function appChannel() {
52809
- return resolveChannel({ isSea: isSea2(), define: true ? "0.29.0" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.31.0" : void 0 });
52810
52849
  }
52811
52850
 
52812
52851
  // oauth-presets.ts
@@ -58225,6 +58264,42 @@ async function startServer() {
58225
58264
  broadcast({ type: "apps-changed", id: req.params.id });
58226
58265
  return { ok: true };
58227
58266
  });
58267
+ app.post("/api/app/:id/freeze", async (req, reply) => {
58268
+ const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId.trim() : "";
58269
+ if (!nodeId) return reply.status(400).send({ ok: false, error: "nodeId required" });
58270
+ try {
58271
+ await aware.freeze(req.params.id, nodeId);
58272
+ } catch (e) {
58273
+ if (e instanceof AwareUnsupportedError) {
58274
+ return reply.status(422).send({ ok: false, error: "Freezing needs AWARE 0.77 or newer \u2014 update AWARE from the footer." });
58275
+ }
58276
+ if (e instanceof AwareError) {
58277
+ app.log.warn({ id: req.params.id, nodeId, detail: e.detail }, "freeze failed");
58278
+ return reply.status(422).send({ ok: false, error: "Couldn't freeze this node \u2014 it may have no saved output yet (run it once first).", reason: awareStderr(e) });
58279
+ }
58280
+ throw e;
58281
+ }
58282
+ broadcast({ type: "apps-changed", id: req.params.id });
58283
+ return { ok: true };
58284
+ });
58285
+ app.post("/api/app/:id/unfreeze", async (req, reply) => {
58286
+ const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId.trim() : "";
58287
+ if (!nodeId) return reply.status(400).send({ ok: false, error: "nodeId required" });
58288
+ try {
58289
+ await aware.unfreeze(req.params.id, nodeId);
58290
+ } catch (e) {
58291
+ if (e instanceof AwareUnsupportedError) {
58292
+ return reply.status(422).send({ ok: false, error: "Freezing needs AWARE 0.77 or newer \u2014 update AWARE from the footer." });
58293
+ }
58294
+ if (e instanceof AwareError) {
58295
+ app.log.warn({ id: req.params.id, nodeId, detail: e.detail }, "unfreeze failed");
58296
+ return reply.status(422).send({ ok: false, error: "Could not unfreeze this node.", reason: awareStderr(e) });
58297
+ }
58298
+ throw e;
58299
+ }
58300
+ broadcast({ type: "apps-changed", id: req.params.id });
58301
+ return { ok: true };
58302
+ });
58228
58303
  app.post("/api/compile", async (req, reply) => {
58229
58304
  const { id, sourcePath } = req.body ?? {};
58230
58305
  const path = sourcePath ?? (id ? readApp(id).source.path : void 0);
package/dist/web/app.css CHANGED
@@ -608,6 +608,20 @@
608
608
  border: 1px solid var(--accent-dim); border-radius: 999px;
609
609
  cursor: help;
610
610
  }
611
+ /* Frozen node (aware app freeze): its last output is pinned and Run skips this node.
612
+ A quiet, INERT read — badge + a one-step-cooler surface — NOT a disabled state (the
613
+ card stays fully interactive; it's just static). Mirrors the rt-model-badge recipe but
614
+ with neutral-cool tokens so it reads distinct from the accent-blue runtime-model badge.
615
+ (No left ::after ribbon: that pseudo-element is already the node mode stripe.) */
616
+ .agent-card.frozen { background: var(--surface-2); }
617
+ .agent-card .frozen-badge {
618
+ display: inline-flex; align-items: center; gap: 4px;
619
+ margin: 0 0 8px; padding: 2px 8px;
620
+ font: 600 10px/1.4 var(--ui); letter-spacing: 0.01em;
621
+ color: var(--text-muted); background: var(--surface-3);
622
+ border: 1px solid var(--border-strong); border-radius: 999px;
623
+ cursor: help;
624
+ }
611
625
  .agent-card .footer-row {
612
626
  display: flex;
613
627
  justify-content: space-between;
@@ -1855,6 +1869,19 @@
1855
1869
  background: var(--border);
1856
1870
  margin: 5px 4px;
1857
1871
  }
1872
+ /* Cursor-anchored node context menu (right-click a card). Reuses .menu/.menu-item; only
1873
+ the fixed 280px width is wrong for it — the pointer anchor (top/left) is set inline. */
1874
+ .menu.context-menu { width: auto; min-width: 168px; }
1875
+ /* Non-interactive node-name header line atop the context menu. */
1876
+ .menu-head {
1877
+ padding: 6px 9px 4px;
1878
+ font-size: 11px;
1879
+ color: var(--text-dim);
1880
+ max-width: 220px;
1881
+ overflow: hidden;
1882
+ text-overflow: ellipsis;
1883
+ white-space: nowrap;
1884
+ }
1858
1885
  .menu-theme {
1859
1886
  display: flex;
1860
1887
  align-items: center;
package/dist/web/app.js CHANGED
@@ -339,7 +339,9 @@ function cardEl(id, ports) {
339
339
  const a = AGENTS[id];
340
340
  ports = ports || { in: 0, out: 0 };
341
341
  const div = document.createElement('div');
342
- div.className = 'agent-card';
342
+ // `frozen` = the node's output is pinned and Run skips it (aware app freeze). A quiet,
343
+ // inert card treatment + badge so it reads as "static / won't re-run" at a glance.
344
+ div.className = 'agent-card' + (a._frozen ? ' frozen' : '');
343
345
  div.dataset.agentId = id;
344
346
  div.draggable = true;
345
347
  const faved = state.favorites.some(f => f.id === id);
@@ -355,6 +357,7 @@ function cardEl(id, ports) {
355
357
  <div class="title">${a.title}</div>
356
358
  <div class="subtitle">${a.subtitle}</div>
357
359
  ${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>' : ''}
360
+ ${a._frozen ? '<div class="frozen-badge" title="Frozen — its last result is pinned and Run skips this node. Right-click to unfreeze.">❄ Frozen</div>' : ''}
358
361
  <div class="blurb">${a.blurb}</div>
359
362
  <div class="footer-row">
360
363
  <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
@@ -294,6 +294,7 @@
294
294
  return {
295
295
  _mode: n.mode,
296
296
  _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
297
+ _frozen: !!n.frozen, // source node carries a `frozen:` block → card badge + Unfreeze menu
297
298
  icon: iconFor(n.agent),
298
299
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
299
300
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
@@ -1392,6 +1393,106 @@
1392
1393
  }
1393
1394
  }
1394
1395
 
1396
+ // ── Right-click Freeze / Unfreeze a node (aware-aeco#261) ─────────────────────
1397
+ // A general per-node toggle: right-click a canvas node → Freeze output (Run then
1398
+ // REPLAYS the node's last result instead of re-running its agent — so e.g. a
1399
+ // drawing-reader node won't re-read & wipe the user's edits, "whatever's there is
1400
+ // there") or Unfreeze (runs normally again). Needs AWARE ≥ 0.77; on an older runtime
1401
+ // the menu simply doesn't appear and the native menu passes through (the app keeps
1402
+ // AWARE at latest, so that's rare/transient — never a dead/disabled control).
1403
+ let _freezeMenuEl = null, _freezeMenuEsc = null, _freezeMenuOutside = null;
1404
+
1405
+ // numeric major.minor.patch compare; an empty/unparseable version sorts BELOW (-1),
1406
+ // so a missing awareVersion safely reads as "freeze unsupported". `Number` (not parseInt)
1407
+ // is strict — a prerelease segment like "0-rc1" → NaN → unsupported (defer to the server
1408
+ // backstop), never silently treated as the release.
1409
+ function cmpSemver(a, b) {
1410
+ const pa = String(a || '').split('.').map((x) => Number(x));
1411
+ const pb = String(b || '').split('.').map((x) => Number(x));
1412
+ if (pa.length < 3 || pa.some(Number.isNaN)) return -1;
1413
+ for (let i = 0; i < 3; i++) {
1414
+ const d = (pa[i] || 0) - (pb[i] || 0);
1415
+ if (d !== 0) return d < 0 ? -1 : 1;
1416
+ }
1417
+ return 0;
1418
+ }
1419
+ function awareSupportsFreeze() { return cmpSemver(awareRuntimeVersion, '0.77.0') >= 0; }
1420
+
1421
+ function closeNodeMenu() {
1422
+ if (_freezeMenuEsc) { document.removeEventListener('keydown', _freezeMenuEsc, true); _freezeMenuEsc = null; }
1423
+ if (_freezeMenuOutside) { document.removeEventListener('mousedown', _freezeMenuOutside, true); _freezeMenuOutside = null; }
1424
+ if (_freezeMenuEl) { _freezeMenuEl.remove(); _freezeMenuEl = null; }
1425
+ }
1426
+
1427
+ // Build + open the cursor-anchored menu over a node card. Reuses the hamburger
1428
+ // `.menu`/`.menu-item` vocabulary (+ a `.context-menu` width modifier) so it matches
1429
+ // the existing popover language; dismiss mirrors openNotesPopover (Escape + click-away).
1430
+ function openNodeMenu(card, x, y) {
1431
+ closeNodeMenu();
1432
+ const nodeId = card.dataset.agentId;
1433
+ if (!nodeId) return;
1434
+ const node = AGENTS[nodeId];
1435
+ const frozen = !!(node && node._frozen);
1436
+ // A 4-line snowflake glyph as a stroke SVG — matches `.menu-icon svg` (Lucide family).
1437
+ const ice = '<span class="menu-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.6" y1="4.6" x2="19.4" y2="19.4"/><line x1="19.4" y1="4.6" x2="4.6" y2="19.4"/></svg></span>';
1438
+ const menu = document.createElement('div');
1439
+ menu.className = 'menu context-menu show';
1440
+ menu.setAttribute('role', 'menu');
1441
+ menu.setAttribute('aria-label', 'Node actions');
1442
+ menu.innerHTML =
1443
+ `<div class="menu-head" role="none">${escapeHtml(nodeId)}</div>`
1444
+ + `<div class="menu-divider"></div>`
1445
+ + `<button type="button" class="menu-item" role="menuitem">${ice}<span class="menu-label">${frozen ? 'Unfreeze' : 'Freeze output'}</span></button>`;
1446
+ document.body.appendChild(menu);
1447
+ // Anchor at the pointer, then clamp so a card near the right/bottom edge can't push
1448
+ // the menu off-screen (same intent as openNotesPopover's edge-clamp).
1449
+ const r = menu.getBoundingClientRect();
1450
+ menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + 'px';
1451
+ menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + 'px';
1452
+ const item = menu.querySelector('.menu-item');
1453
+ item.onclick = () => { closeNodeMenu(); toggleFreeze(nodeId, frozen); };
1454
+ try { item.focus(); } catch { /* container handles focus */ }
1455
+ _freezeMenuEl = menu;
1456
+ _freezeMenuEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeNodeMenu(); try { card.focus(); } catch { /* card not focusable */ } } };
1457
+ document.addEventListener('keydown', _freezeMenuEsc, true);
1458
+ _freezeMenuOutside = (e) => { if (_freezeMenuEl && !_freezeMenuEl.contains(e.target)) closeNodeMenu(); };
1459
+ document.addEventListener('mousedown', _freezeMenuOutside, true);
1460
+ }
1461
+
1462
+ // Toggle a node's frozen state via the server (which shells `aware app freeze/unfreeze`
1463
+ // + recompiles), then re-read the app so the badge + state repaint. api() throws on any
1464
+ // failure (non-2xx OR in-band ok:false) with the server's plain-English message — e.g.
1465
+ // "No output to freeze — run this node at least once first." — so one catch covers it.
1466
+ async function toggleFreeze(nodeId, currentlyFrozen) {
1467
+ if (!currentId) return;
1468
+ const verb = currentlyFrozen ? 'unfreeze' : 'freeze';
1469
+ try {
1470
+ await api(`/api/app/${encodeURIComponent(currentId)}/${verb}`, { method: 'POST', body: JSON.stringify({ nodeId }) });
1471
+ await loadApp(currentId); // re-read → frozen badge/state repaints
1472
+ showToast(currentlyFrozen
1473
+ ? 'Node unfrozen — it will run normally.'
1474
+ : 'Output frozen — this node will replay its last result on the next run.',
1475
+ currentlyFrozen ? 'info' : 'ok');
1476
+ } catch (e) {
1477
+ // Surface the real aware reason the route attached as body.reason — otherwise the friendly
1478
+ // lead hides the actual cause. First non-empty line, capped, keeps the warn toast sane.
1479
+ const lead = (e && e.message) || `Could not ${verb} this node.`;
1480
+ const raw = e && e.body && typeof e.body.reason === 'string' ? e.body.reason.trim() : '';
1481
+ const reason = raw ? (raw.split('\n').find((l) => l.trim()) || '').trim().slice(0, 200) : '';
1482
+ showToast(reason ? `${lead} — ${reason}` : lead, 'warn');
1483
+ }
1484
+ }
1485
+
1486
+ // Right-click a node card → the Freeze/Unfreeze menu. preventDefault is scoped to cards
1487
+ // (right-click stays normal everywhere else), and only when AWARE supports it — on an
1488
+ // older runtime the native menu passes through, so there's no dead interaction.
1489
+ document.addEventListener('contextmenu', (e) => {
1490
+ const card = e.target.closest && e.target.closest('.agent-card');
1491
+ if (!card || !awareSupportsFreeze()) return;
1492
+ e.preventDefault();
1493
+ openNodeMenu(card, e.clientX, e.clientY);
1494
+ });
1495
+
1395
1496
  // Approve & bake — freezes the edited contract into the .lock, arming Run.
1396
1497
  // Flushes the editor's pending debounce save first so Approve always bakes the
1397
1498
  // latest in-memory edits, not a stale snapshot. api() throws on non-2xx, so
@@ -3284,6 +3385,8 @@
3284
3385
  }
3285
3386
  let shownVersion = false;
3286
3387
  let loadedAppVersion = null; // the build this tab is running against (first health wins)
3388
+ let awareRuntimeVersion = null; // installed AWARE version (from /api/health) — gates the freeze menu
3389
+ let freezeHintAppended = false; // append the right-click-to-freeze canvas hint once, when supported
3287
3390
  function startHealthPoll() {
3288
3391
  const tick = async () => {
3289
3392
  let ok = false;
@@ -3326,6 +3429,15 @@
3326
3429
  // After the build version is stamped, reveal the relaunch-surviving what's-new
3327
3430
  // panel iff this is the build we just self-updated into (guarded to once).
3328
3431
  maybeShowWhatsNew();
3432
+ // Track the installed AWARE runtime version so the right-click freeze menu can
3433
+ // gate itself (needs ≥ 0.77) and the canvas hint can advertise it once available.
3434
+ if (h && h.awareVersion) {
3435
+ awareRuntimeVersion = h.awareVersion;
3436
+ if (!freezeHintAppended && awareSupportsFreeze()) {
3437
+ const ch = document.getElementById('canvas-hint');
3438
+ if (ch) { ch.textContent += ' Right-click a node to freeze its output.'; freezeHintAppended = true; }
3439
+ }
3440
+ }
3329
3441
  const wv = document.getElementById('aware-version');
3330
3442
  if (wv && h && h.awareVersion) {
3331
3443
  const next = 'AWARE ' + h.awareVersion;
@@ -61,12 +61,30 @@
61
61
  rect.dethot{cursor:pointer;fill:rgba(168,85,247,.16);stroke:#a855f7;stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke}
62
62
  rect.dethot:hover{fill:rgba(168,85,247,.34)}
63
63
  text.dettx{fill:#ddd6fe;font:bold 11px system-ui;text-anchor:middle;pointer-events:none}
64
- #detailsModal,#framesModal,#rfiModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
64
+ #detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
65
65
  #askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
66
66
  .aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
67
67
  .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
68
68
  #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
69
69
  #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
70
+ #confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
71
+ .conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
72
+ .ccard{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px 12px}
73
+ .ccard.click{cursor:pointer} .ccard.click:hover{border-color:var(--brand)} .ccard.on{border-color:var(--brand)}
74
+ .ccard .cc-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:4px}
75
+ .ccard .cc-score{font-size:22px;font-weight:700;line-height:1;margin-bottom:6px}
76
+ .ccard .cc-bar{height:4px;border-radius:2px;background:var(--line);margin-bottom:6px;overflow:hidden}
77
+ .ccard .cc-fill{height:100%;border-radius:2px}
78
+ .ccard .cc-counts{font-size:11px;color:var(--mut);display:flex;gap:8px;flex-wrap:wrap}
79
+ .conf-filter{display:flex;gap:12px;flex-wrap:wrap;padding:12px 14px;border-bottom:1px solid var(--line);align-items:center}
80
+ .conf-filter .glab{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
81
+ .conf-filter .grp{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
82
+ .conf-filter .grp button{border:0;border-radius:0;background:transparent;color:var(--mut);font-size:11px;padding:4px 9px;cursor:pointer}
83
+ .conf-filter .grp button.on{background:var(--brand);color:#fff}
84
+ .conf-body{padding:0 14px 14px;overflow:auto;flex:1;min-height:0}
85
+ .chip{display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;border:1px solid var(--line);color:var(--mut);margin:0 3px 3px 0}
86
+ .chip.assumed,.chip.weak{border-color:#713f12;color:#fbbf24} .chip.fail{border-color:#7f1d1d;color:#fca5a5}
87
+ tr.confrow{cursor:pointer} .conf-expand{color:var(--mut);font-size:12px;line-height:1.6;background:#0b1220}
70
88
  .ftab td .combo{padding:4px 6px} .ftab .rea{color:var(--mut);font-size:12px} .rfichip{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:#7f1d1d;color:#fecaca;border-radius:9px;font-size:11px;padding:0 5px}
71
89
  .mbackdrop{position:absolute;inset:0;background:rgba(2,8,23,.62)}
72
90
  .mpanel{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;width:min(880px,92vw);max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.6)}
@@ -115,6 +133,7 @@
115
133
  <span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
116
134
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
117
135
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
136
+ <span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
118
137
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span><span style="flex:1"></span>
119
138
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
120
139
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
@@ -160,6 +179,11 @@
160
179
  <div id=rfiModal><div class=mbackdrop id=rfiBackdrop></div>
161
180
  <div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
162
181
  <div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
182
+ <div id=confModal><div class=mbackdrop id=confBackdrop></div>
183
+ <div class=mpanel><div class=mhead><b>Confidence report</b><button id=confClose>✕</button></div>
184
+ <div id=confCats class=conf-cats></div>
185
+ <div id=confFilter class=conf-filter></div>
186
+ <div id=confBody class=conf-body></div></div></div>
163
187
  <div id=askAiModal><div class=mbackdrop id=askAiBackdrop></div>
164
188
  <div class=mpanel style="width:min(560px,92vw)">
165
189
  <div class=mhead><b>Ask the AI</b><button id=askAiClose>✕</button></div>
@@ -429,7 +453,7 @@ function render(){
429
453
  selM.forEach((m,idx)=>{const c=mid(m),k=Math.round(c[0]/8)+','+Math.round(c[1]/8);(grp[k]=grp[k]||[]).push({idx,c});});
430
454
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
431
455
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
432
- svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup();
456
+ svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf();
433
457
  }
434
458
  function updDup(){const n=redundantDups().length;
435
459
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
@@ -500,6 +524,7 @@ function panel(){
500
524
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
501
525
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
502
526
  <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
527
+ <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
503
528
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
504
529
  ${elev}
505
530
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
@@ -510,7 +535,7 @@ function panel(){
510
535
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
511
536
  document.getElementById('rBeam').onclick=()=>{if(col)edit(()=>{m.role='beam';});};
512
537
  document.getElementById('rCol').onclick=()=>{if(!col)edit(()=>{m.role='column';});};
513
- document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}}));
538
+ document.querySelectorAll('.mfsug').forEach(b=>b.onclick=()=>edit(()=>{m.profile=b.dataset.s;m.mf=true;m.rfi=(WT[m.profile]==null);if(!profs.includes(m.profile)){profs.push(m.profile);profs.sort();}})); // record the moment-frame provenance (drives the confidence "schedule-resolved" factor)
514
539
  const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
515
540
  if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
516
541
  if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
@@ -534,6 +559,7 @@ function panel(){
534
559
  document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});
535
560
  }
536
561
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
562
+ {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
537
563
  }
538
564
  function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const P=addProfile;const id='m'+Date.now();const pv=snapshot();
539
565
  P.members.push(ensureMeta({id,profile:P,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[P]==null)}));
@@ -643,6 +669,7 @@ addEventListener('keydown',e=>{
643
669
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
644
670
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
645
671
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
672
+ if(e.key==='Escape'&&confOpen()){closeConf();return;}
646
673
  if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
647
674
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
648
675
  if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
@@ -780,6 +807,96 @@ function rfiOpen(){return document.getElementById('rfiModal').style.display==='f
780
807
  document.getElementById('rfiStat').onclick=openRFI;
781
808
  document.getElementById('rfiClose').onclick=closeRFI;
782
809
  document.getElementById('rfiBackdrop').onclick=closeRFI;
810
+ // ===== Confidence report — evidence-based score (MIRROR of server/steel-confidence.ts; keep in sync) =====
811
+ // Deterministic: scores ONLY observable evidence already in the contract, never a model self-report.
812
+ const BANDW={verified:1,high:.9,med:.65,low:.35,rfi:0};
813
+ const BANDLAB={verified:'Verified',high:'High',med:'Medium',low:'Low',rfi:'RFI'};
814
+ const BANDC={verified:'#86efac',high:'#4ade80',med:'#fbbf24',low:'#fca5a5',rfi:'#fca5a5'};
815
+ const BANDPILL={verified:'background:#166534;color:#86efac',high:'background:#14532d;color:#4ade80',med:'background:#713f12;color:#fbbf24',low:'background:#7f1d1d;color:#fca5a5',rfi:'background:#7f1d1d;color:#fca5a5'};
816
+ function _wt(profile){if(!WT||!profile)return null;if(profile in WT)return WT[profile]==null?null:WT[profile];
817
+ const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());return h?(h[1]==null?null:h[1]):null;}
818
+ const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
819
+ function _confDupIds(members){const g={};const key=m=>{if(!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;};
820
+ const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
821
+ for(const m of members){const k=key(m);if(!k)continue;(g[k]=g[k]||[]).push(m);}
822
+ const out=new Set();for(const k in g){const grp=g[k];if(grp.length<2)continue;grp.sort((a,b)=>rank(b)-rank(a));for(let i=1;i<grp.length;i++)out.add(grp[i].id);}return out;}
823
+ function _elevAssumed(m){if(m.role==='column')return !(m.col&&m.col.tosDef===false);const en=m.ends||[];if(!en.length)return true;return en.some(e=>e.tosDef!==false);}
824
+ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
825
+ if(plf==null){F.push({key:'profile',label:'Profile',state:'fail',detail:(!m.profile||!m.profile.trim())?'no profile assigned':_isMf(m.profile)?('unresolved mark "'+m.profile+'" — not an AISC size'):('"'+m.profile+'" not in the AISC weight table')});return {band:'rfi',factors:F};}
826
+ const sched=m.mf===true;F.push({key:'profile',label:'Profile',state:sched?'assumed':'ok',detail:sched?(m.profile+' — resolved from the frame schedule ('+plf+' plf)'):(m.profile+' ('+plf+' plf, AISC)')});
827
+ const asm=_elevAssumed(m);F.push({key:'elevation',label:'Elevation',state:asm?'assumed':'ok',detail:asm?'plan default (UNO) — no local callout':'from a drawing callout'});
828
+ const isd=dup.has(m.id);if(isd)F.push({key:'dup',label:'Duplicate',state:'fail',detail:'coincident with a kept member — deleting dedupes the BOM'});
829
+ if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
830
+ const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
831
+ function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft;
832
+ if(m.role==='column'){if(!m.col||m.col.tos==null||m.col.bos==null)return 0;ft=Math.abs(m.col.tos-m.col.bos)/12;} // column length = height (TOS−BOS), inches→ft
833
+ else{if(!m.wp||m.wp.length<2)return 0;ft=Math.hypot(m.wp[0][0]-m.wp[1][0],m.wp[0][1]-m.wp[1][1])/(ptPerFt>0?ptPerFt:1);}
834
+ return ft*plf/2000;}
835
+ function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
836
+ const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
837
+ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[];let segs=0;
838
+ const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
839
+ plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
840
+ for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
841
+ for(const d of (plan.details||[])){const sh=_detSheet(d.text);const ok=sh!=null&&known.has(sh.toUpperCase());byDetail.push({text:d.text||'',planIdx:pi,sheet:plan.sheet||'',band:ok?'high':'low',reason:ok?('references '+sh+' (in the set)'):sh?('references '+sh+' — not found in the set'):'no sheet reference parsed'});}});
842
+ const roll=(arr,cw)=>{const c=_cnt0();let n=0,d=0,t=0;for(const m of arr){c[m.band]++;if(m.band==='rfi')continue;const w=cw?1:m.tons;n+=w*BANDW[m.band];d+=w;t+=m.tons;}return {score:d>0?Math.round(n/d*100):null,tons:t,counts:c};};
843
+ const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
844
+ const beams=roll(byMember.filter(m=>m.role==='beam'));const columns=roll(byMember.filter(m=>m.role==='column'));const details=dRoll(byDetail);
845
+ const sc=byMember.filter(m=>m.band!=='rfi');const n=sc.reduce((s,m)=>s+m.tons*BANDW[m.band],0),d=sc.reduce((s,m)=>s+m.tons,0);
846
+ return {byMember,byDetail,byCategory:{beams,columns,details,connections:{score:null,note:'Not scored yet (reserved slot)',counts:_cnt0()}},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
847
+ function bandColorForPct(p){return p==null?'var(--mut)':p>=95?BANDC.verified:p>=80?BANDC.high:p>=50?BANDC.med:BANDC.low;}
848
+ function updConf(){const el=document.getElementById('confStat');if(!el)return;const s=scoreContractJS();
849
+ if(!s.byMember.length){el.style.display='none';return;}el.style.display='';
850
+ const b=document.getElementById('confPct');b.textContent=s.overall.score==null?'—':s.overall.score+'%';b.style.color=bandColorForPct(s.overall.score);
851
+ el.title='Confidence — '+s.overall.tons.toFixed(1)+' t scored · '+s.overall.rfiCount+' RFI. Click for the report.';}
852
+ // --- the report modal ---
853
+ let confBand='all',confCat='all',confExpand=null;
854
+ function openConf(){confExpand=null;renderConf();document.getElementById('confModal').style.display='flex';}
855
+ function closeConf(){document.getElementById('confModal').style.display='none';}
856
+ function confOpen(){return document.getElementById('confModal').style.display==='flex';}
857
+ function _bar(pct){return '<div class=cc-bar><div class=cc-fill style="width:'+(pct||0)+'%;background:'+bandColorForPct(pct)+';'+(pct==null?'opacity:0':'')+'"></div></div>';}
858
+ function _countsHtml(c){const out=['verified','high','med','low','rfi'].filter(k=>c[k]>0).map(k=>'<span style="color:'+BANDC[k]+'">'+BANDLAB[k][0]+' <b>'+c[k]+'</b></span>').join('');return out||'<span>—</span>';}
859
+ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
860
+ const card=(key,label,cs,stub)=>'<div class="ccard'+(stub?' stub':' click')+(confCat===key?' on':'')+'" data-cat="'+key+'"'+(stub?' title="not scored yet"':'')+'>'+
861
+ '<div class=cc-label>'+label+'</div>'+
862
+ '<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
863
+ _bar(stub?null:cs.score)+
864
+ '<div class=cc-counts>'+(stub?'<span>reserved slot</span>':_countsHtml(cs.counts))+'</div></div>';
865
+ document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections,true);
866
+ const bands=['all','verified','high','med','low','rfi'],cats=['all','beams','columns','details','connections'];
867
+ document.getElementById('confFilter').innerHTML=
868
+ '<span class=glab>Band</span><span class=grp>'+bands.map(b=>'<button data-band="'+b+'" class="'+(confBand===b?'on':'')+'">'+(b==='all'?'All':BANDLAB[b])+'</button>').join('')+'</span>'+
869
+ '<span class=glab>Category</span><span class=grp>'+cats.map(c=>'<button data-fcat="'+c+'" class="'+(confCat===c?'on':'')+'">'+(c==='all'?'All':c[0].toUpperCase()+c.slice(1))+'</button>').join('')+'</span>'+
870
+ '<span class=glab style="margin-left:auto">Coverage: '+s.coverage.members+' members · '+s.coverage.segments+' segments (approx, v1)</span>';
871
+ document.getElementById('confBody').innerHTML=confBodyHtml(s);
872
+ wireConf();}
873
+ function confBodyHtml(s){
874
+ if(!s.byMember.length&&!s.byDetail.length)return '<div class=hint style="padding:14px 0">No members in the takeoff yet.</div>';
875
+ if(confCat==='connections')return '<div class=hint style="padding:14px 0">Connection scoring is not built yet (reserved slot).</div>';
876
+ let mem=s.byMember.slice();
877
+ if(confCat==='beams')mem=mem.filter(m=>m.role==='beam');else if(confCat==='columns')mem=mem.filter(m=>m.role==='column');else if(confCat==='details')mem=[];
878
+ if(confBand!=='all')mem=mem.filter(m=>m.band===confBand);
879
+ let det=(confCat==='all'||confCat==='details')?s.byDetail.slice():[];
880
+ if(confBand!=='all')det=det.filter(d=>d.band===confBand);
881
+ let h='<table class=ftab><thead><tr><th>Band</th><th>Element</th><th>Sheet</th><th>Tons</th><th>Evidence</th><th></th></tr></thead><tbody>';
882
+ mem.forEach(m=>{const chips=m.factors.map(f=>'<span class="chip '+(f.state==='ok'?'':f.state)+'">'+esc(f.label)+(f.state==='ok'?' ✓':f.state==='fail'?' ✗':' ●')+'</span>').join('');
883
+ h+='<tr class=confrow data-mid="'+esc(m.id)+'" data-pi="'+m.planIdx+'"><td><span class=pill style="'+BANDPILL[m.band]+'">'+BANDLAB[m.band]+'</span></td>'+
884
+ '<td>'+(esc(m.profile)||'<span style="color:var(--mut)">(no profile)</span>')+' <span style="color:var(--mut);font-size:11px">'+esc(m.role)+'</span></td>'+
885
+ '<td>'+esc(m.sheet)+'</td><td>'+(m.tons?m.tons.toFixed(2):'—')+'</td><td>'+chips+'</td>'+
886
+ '<td><button class=ghost data-loc="'+esc(m.id)+'" data-pi="'+m.planIdx+'">Locate</button></td></tr>';
887
+ if(confExpand===m.id)h+='<tr><td></td><td colspan=5 class=conf-expand>'+m.factors.map(f=>'<div>• <b>'+esc(f.label)+':</b> '+esc(f.detail)+'</div>').join('')+'</td></tr>';});
888
+ det.forEach(d=>{h+='<tr><td><span class=pill style="'+BANDPILL[d.band]+'">'+BANDLAB[d.band]+'</span></td><td>'+(esc(d.text)||'(detail)')+' <span style="color:var(--mut);font-size:11px">detail</span></td><td>'+esc(d.sheet)+'</td><td>—</td><td><span class=hint>'+esc(d.reason)+'</span></td><td></td></tr>';});
889
+ if(!mem.length&&!det.length)h+='<tr><td colspan=6><div class=hint style="padding:14px 0">Nothing matches this filter.</div></td></tr>';
890
+ h+='</tbody></table>';return h;}
891
+ function wireConf(){
892
+ document.querySelectorAll('#confCats .ccard.click').forEach(c=>c.onclick=()=>{confCat=(confCat===c.dataset.cat?'all':c.dataset.cat);renderConf();});
893
+ document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
894
+ document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
895
+ document.querySelectorAll('#confBody tr.confrow').forEach(r=>r.onclick=e=>{if(e.target.closest('button'))return;const id=r.dataset.mid;confExpand=confExpand===id?null:id;renderConf();});
896
+ document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
897
+ document.getElementById('confStat').onclick=openConf;
898
+ document.getElementById('confClose').onclick=closeConf;
899
+ document.getElementById('confBackdrop').onclick=closeConf;
783
900
  // --- detail preview lightbox (click a card / an end's ⤢ to enlarge) ---
784
901
  const SHEET_PREV=C.sheet_previews||{}, DBUB=C.detail_bubbles||{};
785
902
  function sheetOf(text){const m=/(S-?\w+)/.exec(String(text||'').replace(/^\w+-/,''));if(!m)return null;const s=m[1].toUpperCase();return s.startsWith('S-')?s:'S-'+s.slice(1);}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.29.0",
3
+ "version": "0.31.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": {