@floless/app 0.54.1 → 0.56.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.
package/dist/web/aware.js CHANGED
@@ -531,11 +531,208 @@
531
531
  });
532
532
  }
533
533
 
534
+ // ── Per-workflow onboarding beacon ──────────────────────────────────────────
535
+ // "New to you" = the user hasn't completed a first real Run of this workflow and
536
+ // hasn't dismissed its guide. Three bits per app id, persisted per origin; all
537
+ // default false, so every workflow starts new. Never touches the .flo or lock.
538
+ const LS_GUIDE = 'floless:guide';
539
+ function guideStateAll() {
540
+ try { const v = JSON.parse(localStorage.getItem(LS_GUIDE) || '{}'); return (v && typeof v === 'object') ? v : {}; }
541
+ catch { return {}; }
542
+ }
543
+ function guideFor(id) { return guideStateAll()[id] || {}; }
544
+ function setGuide(id, patch) {
545
+ if (!id) return;
546
+ try {
547
+ const all = guideStateAll();
548
+ all[id] = { ...(all[id] || {}), ...patch };
549
+ localStorage.setItem(LS_GUIDE, JSON.stringify(all));
550
+ } catch { /* private mode / quota */ }
551
+ }
552
+ // The beacon pulses while a workflow is new to the user.
553
+ function beaconIsNew(id) { const g = guideFor(id); return !g.firstRunDone && !g.dismissed; }
554
+
555
+ function paintBeacon(app) {
556
+ const $b = document.getElementById('guide-beacon');
557
+ if (!$b) return;
558
+ if (!app) { $b.hidden = true; $b.classList.remove('is-new', 'spotlit'); return; }
559
+ const isNew = beaconIsNew(app.id);
560
+ $b.hidden = false;
561
+ $b.classList.toggle('is-new', isNew);
562
+ const ico = $b.querySelector('.guide-beacon-ico');
563
+ const label = $b.querySelector('.guide-beacon-label');
564
+ // Monochrome dingbats (✦ / ?), not colour emoji — the locked baseline uses text
565
+ // glyphs (★, ▸); the pulse, not an emoji, is the "new" signal.
566
+ if (ico) ico.textContent = isNew ? '✦' : '?';
567
+ if (label) label.textContent = isNew ? 'Start here' : 'Guide';
568
+ $b.dataset.tip = isNew
569
+ ? 'New here? See what this workflow does and how to run it.'
570
+ : 'Open the guide for this workflow.';
571
+ $b.setAttribute('aria-label', (isNew ? 'Start here — guide for ' : 'Guide for ') + (app.displayName || app.id));
572
+ }
573
+
574
+ // Record a completed real run → the workflow is no longer new; calm the beacon (and
575
+ // refresh an open guide so the Run step ticks). Idempotent.
576
+ function markFirstRunDone(id) {
577
+ if (!id || guideFor(id).firstRunDone) return;
578
+ setGuide(id, { firstRunDone: true });
579
+ if (id === currentId) {
580
+ const app = apps.get(id);
581
+ if (app) paintBeacon(app);
582
+ const $m = document.getElementById('guide-modal');
583
+ if ($m && $m.classList.contains('show')) renderGuide();
584
+ }
585
+ }
586
+
587
+ // The guide panel's live checklist — each step ticks from real app state. The
588
+ // "Set inputs" row is omitted for a workflow with no declared inputs.
589
+ function guideSteps(app) {
590
+ const steps = [];
591
+ if (Array.isArray(app.inputs) && app.inputs.length) {
592
+ const vals = currentInputs();
593
+ const allSet = app.inputs.every((i) => {
594
+ const v = vals[i.name];
595
+ return v !== undefined && v !== null && String(v).trim() !== '';
596
+ });
597
+ steps.push({ label: 'Set inputs', note: 'the values this workflow runs with', done: allSet });
598
+ }
599
+ steps.push({ label: 'Compile', note: 'freeze the approved lock', done: app.runState === 'ready' });
600
+ steps.push({ label: 'Run', note: 'against the live host — results render here', done: !!guideFor(app.id).firstRunDone });
601
+ return steps;
602
+ }
603
+
604
+ function renderGuide() {
605
+ const app = currentId && apps.get(currentId);
606
+ const $body = document.getElementById('guide-body');
607
+ const $title = document.getElementById('guide-title');
608
+ const $eyebrow = document.getElementById('guide-eyebrow');
609
+ if (!app || !$body) return;
610
+ if ($title) $title.textContent = app.displayName || app.id;
611
+ if ($eyebrow) $eyebrow.textContent = beaconIsNew(app.id) ? '✦ Start here' : '✦ Workflow guide';
612
+ const desc = (app.description || '').trim();
613
+ const descHtml = desc
614
+ ? `<div class="guide-desc">${desc.split(/\n{2,}/).map((p) => `<p>${mdInline(escapeHtml(p.trim()))}</p>`).join('')}</div>`
615
+ : `<div class="guide-desc guide-desc-empty"><p>A ${app.nodes.length}-step workflow. Set its inputs, Compile to freeze the approved lock, then Run it against your live host.</p></div>`;
616
+ const steps = guideSteps(app);
617
+ const curIdx = steps.findIndex((s) => !s.done);
618
+ const stepsHtml = `<ol class="guide-steps" role="list">${steps.map((s, i) => {
619
+ const state = s.done ? 'done' : i === curIdx ? 'current' : 'todo';
620
+ // Shape carries state, not colour alone: ✓ done · → current · ☐ to-do.
621
+ const box = s.done ? '✓' : i === curIdx ? '→' : '☐';
622
+ const cur = i === curIdx ? ' aria-current="step"' : '';
623
+ return `<li class="guide-step ${state}"${cur}><span class="guide-box" aria-hidden="true">${box}</span>` +
624
+ `<span class="guide-step-text"><span class="guide-step-label">${escapeHtml(s.label)}</span> ` +
625
+ `<span class="guide-step-note">— ${escapeHtml(s.note)}</span></span>` +
626
+ `<span class="sr-only">${s.done ? '(done)' : i === curIdx ? '(next step)' : '(to do)'}</span></li>`;
627
+ }).join('')}</ol>`;
628
+ const walkHint = `<div class="guide-walk-hint">A bigger workflow? <strong>Walk me through it</strong> hands off to your terminal AI for a step-by-step tour using this workflow's own skill.</div>`;
629
+ $body.innerHTML =
630
+ `<div class="guide-section-label">What this does</div>` + descHtml +
631
+ `<div class="guide-section-label">How to go through it</div>` + stepsHtml + walkHint;
632
+ }
633
+
634
+ function openGuide() {
635
+ if (!currentId) { showToast('open a workflow first', 'warn'); return; }
636
+ renderGuide();
637
+ const $m = document.getElementById('guide-modal');
638
+ showModal($m);
639
+ // Focus the dialog shell (not a button) so the checklist is read first; Tab then
640
+ // moves to Got it → Walk me through it. Focus returns to the beacon on close.
641
+ const shell = $m && $m.querySelector('.modal.guide');
642
+ if (shell) { try { shell.focus(); } catch { /* not focusable */ } }
643
+ }
644
+ // Closing the guide (Got it / backdrop / Esc) marks it seen → the beacon stops
645
+ // pulsing even before a first run (the user has looked; don't nag).
646
+ function closeGuide() {
647
+ if (currentId) {
648
+ setGuide(currentId, { dismissed: true });
649
+ const app = apps.get(currentId);
650
+ if (app) paintBeacon(app);
651
+ }
652
+ hideModal(document.getElementById('guide-modal'));
653
+ const $b = document.getElementById('guide-beacon');
654
+ if ($b && !$b.hidden) { try { $b.focus(); } catch { /* */ } }
655
+ }
656
+
657
+ // "Walk me through it" → relay the ask to the terminal AI, the house way: queue a
658
+ // request AND copy the prompt, toast, light the footer requests pill, list it in the
659
+ // Requests window. The UI never guides itself; it hands intent to the assistant.
660
+ async function requestGuide() {
661
+ if (!currentId) { showToast('open a workflow first', 'warn'); return; }
662
+ try {
663
+ const { request } = await api('/api/guide', { method: 'POST', body: JSON.stringify({ appId: currentId }) });
664
+ const copied = await copyToClipboard(markedInstruction(request));
665
+ showToast(copied ? 'Sent to your assistant · copied to clipboard' : 'Sent to your assistant', 'ok');
666
+ appendNarration(`Asked your terminal AI to walk you through <code>${escapeHtml(currentId)}</code> — watch the glowing <strong>requests</strong> pill (bottom-right), or switch to your terminal.${copied ? ' The prompt is also on your clipboard.' : ''}`);
667
+ loadRequests();
668
+ closeGuide(); // engaging with the guidance also calms the beacon
669
+ } catch (e) { reportErr(e); }
670
+ }
671
+
672
+ // ≡ menu "New workflow…" — the create-from-scratch sibling of requestGuide(). No open
673
+ // workflow to target (none exists yet), so no currentId gate: relay the ask to the
674
+ // terminal AI the same house way (queue a request, copy the marked prompt, toast, light
675
+ // the requests pill). The UI never authors the workflow itself; the AI's
676
+ // floless-app-new-workflow skill does, step by step.
677
+ async function requestNewWorkflow() {
678
+ try {
679
+ const { request } = await api('/api/guide-new', { method: 'POST', body: '{}' });
680
+ const copied = await copyToClipboard(markedInstruction(request));
681
+ showToast(copied ? 'Building your workflow · copied to clipboard' : 'Building your workflow', 'ok');
682
+ appendNarration(`Asked your terminal AI to interview you and <strong>build a new workflow</strong> from scratch — watch the glowing <strong>requests</strong> pill (bottom-right), or switch to your terminal.${copied ? ' The prompt is also on your clipboard.' : ''}`);
683
+ loadRequests();
684
+ } catch (e) { reportErr(e); }
685
+ }
686
+
687
+ // ── First-open spotlight (one-time per workflow) ────────────────────────────
688
+ // Dims the canvas, raises the beacon, and points a small caption at it; Esc /
689
+ // click-away / either button dismiss it for good. Slice 2 of the onboarding beacon.
690
+ let _spotlightKey = null;
691
+ function maybeSpotlight(app) {
692
+ if (!app) return;
693
+ const g = guideFor(app.id);
694
+ if (g.spotlightShown || g.dismissed || g.firstRunDone) return;
695
+ if (document.querySelector('.modal-backdrop.show')) return; // don't pile on a dialog
696
+ if ($contractEditor && !$contractEditor.hidden) return;
697
+ setTimeout(() => {
698
+ if (currentId !== app.id) return; // switched away during the delay
699
+ if (guideFor(app.id).spotlightShown) return;
700
+ if (document.querySelector('.modal-backdrop.show')) return;
701
+ showSpotlight(app);
702
+ }, 700);
703
+ }
704
+ function showSpotlight(app) {
705
+ const $sp = document.getElementById('guide-spotlight');
706
+ const $b = document.getElementById('guide-beacon');
707
+ if (!$sp || !$b || $b.hidden) return;
708
+ setGuide(app.id, { spotlightShown: true });
709
+ $b.classList.add('spotlit');
710
+ $sp.hidden = false;
711
+ // Anchor the caption bubble just under the beacon, right-aligned to it.
712
+ const bubble = $sp.querySelector('.guide-spotlight-bubble');
713
+ if (bubble) {
714
+ const r = $b.getBoundingClientRect();
715
+ bubble.style.top = (r.bottom + 10) + 'px';
716
+ bubble.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
717
+ }
718
+ _spotlightKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); hideSpotlight(); } };
719
+ document.addEventListener('keydown', _spotlightKey);
720
+ if (bubble) { try { bubble.focus(); } catch { /* */ } }
721
+ }
722
+ function hideSpotlight() {
723
+ const $sp = document.getElementById('guide-spotlight');
724
+ const $b = document.getElementById('guide-beacon');
725
+ if ($sp) $sp.hidden = true;
726
+ if ($b) $b.classList.remove('spotlit');
727
+ if (_spotlightKey) { document.removeEventListener('keydown', _spotlightKey); _spotlightKey = null; }
728
+ }
729
+
534
730
  // ── load / select / compile / run ───────────────────────────────────────────
535
731
 
536
732
  let _wfUpdates = []; // workflow updates available, keyed by app id — polled by refreshWorkflowUpdates() below
537
733
 
538
734
  async function loadApp(id) {
735
+ hideSpotlight(); // a spotlight pointing at the previous app's beacon must clear on switch
539
736
  const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
540
737
  apps.set(id, app);
541
738
  currentId = id;
@@ -555,8 +752,10 @@
555
752
  seedAppInputs(app);
556
753
  markSpecialNodes();
557
754
  paintGate(app);
755
+ paintBeacon(app); // pulse "Start here" while this workflow is new to the user
558
756
  refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
559
757
  renderWfUpdatePill(); // show the "↑ Update to vX" pill when THIS app has a newer published version
758
+ maybeSpotlight(app); // one-time first-open spotlight pointing at the beacon (slice 2)
560
759
  }
561
760
 
562
761
  async function loadApps() {
@@ -574,6 +773,8 @@
574
773
  if ($menuBakeItem) $menuBakeItem.disabled = true;
575
774
  if ($wfTrigger) $wfTrigger.disabled = true;
576
775
  setComboTriggerLabel('no workflows installed');
776
+ paintBeacon(null); // no workflow open → hide the per-workflow beacon
777
+ hideSpotlight();
577
778
  renderCanvasPlaceholder('empty');
578
779
  return;
579
780
  }
@@ -814,8 +1015,18 @@
814
1015
  if (v != null) localStorage.setItem(lsInputsKey(toId), v);
815
1016
  } catch { /* private mode / quota */ }
816
1017
  }
1018
+ // A rename is the SAME workflow under a new id → carry its onboarding-seen state so it
1019
+ // doesn't re-pulse "Start here" / re-show the spotlight. (Not done on duplicate: a copy
1020
+ // is a genuinely new workflow that the user hasn't run, so it should start new.)
1021
+ function migrateGuideLS(fromId, toId) {
1022
+ try {
1023
+ const all = guideStateAll();
1024
+ if (all[fromId]) { all[toId] = all[fromId]; localStorage.setItem(LS_GUIDE, JSON.stringify(all)); }
1025
+ } catch { /* private mode / quota */ }
1026
+ }
817
1027
  function dropClientAppState(id) {
818
1028
  try { localStorage.removeItem(lsInputsKey(id)); } catch { /* ignore */ }
1029
+ try { const all = guideStateAll(); if (all[id]) { delete all[id]; localStorage.setItem(LS_GUIDE, JSON.stringify(all)); } } catch { /* ignore */ }
819
1030
  appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
820
1031
  }
821
1032
 
@@ -843,6 +1054,7 @@
843
1054
  // so a rename never silently drops the user's inputs (#85 review). loadApp() only seeds
844
1055
  // inputs when the id is absent, so pre-setting them here preserves unsaved edits.
845
1056
  migrateInputsLS(oldId, r.id);
1057
+ migrateGuideLS(oldId, r.id); // carry onboarding-seen state so the renamed workflow doesn't re-pulse
846
1058
  if (appInputValues.has(oldId)) appInputValues.set(r.id, appInputValues.get(oldId));
847
1059
  if (dirtyBaseline.has(oldId)) dirtyBaseline.set(r.id, dirtyBaseline.get(oldId));
848
1060
  const wasOpen = currentId === oldId;
@@ -1767,6 +1979,7 @@
1767
1979
  $reportSub.innerHTML = `Live 3D view from <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — drag to orbit, scroll to zoom, click an element to inspect.`;
1768
1980
  }
1769
1981
  paintReport(html, interactive);
1982
+ markFirstRunDone(app.id); // a real run rendered → this workflow is no longer "new" (app captured pre-await; a mid-run switch must not mark the wrong workflow)
1770
1983
  lastReportByApp.set(currentId, { nodeId, label: inputBadge, html, interactive });
1771
1984
  $reportModal.dataset.html = '1';
1772
1985
  appendNarration(`Rendered the ${interactive ? '3D view' : 'report'} from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
@@ -2538,6 +2751,7 @@
2538
2751
  // app.js's doOpen/doSave are reassigned from this file).
2539
2752
  const _handleMenuAction = handleMenuAction;
2540
2753
  handleMenuAction = function (action) {
2754
+ if (action === 'new-workflow') { requestNewWorkflow(); return; }
2541
2755
  if (action === 'graft') { openGraftModal(); return; }
2542
2756
  if (action === 'bake') { openBakeModal(); return; }
2543
2757
  if (action === 'import') { triggerImport(); return; }
@@ -3215,6 +3429,7 @@
3215
3429
  appendNarration(simulate
3216
3430
  ? `Simulated run complete · ${steps} node${steps === 1 ? '' : 's'} stubbed (no host needed) · see the <strong>Execution</strong> tab.`
3217
3431
  : `Run complete · ${steps} node${steps === 1 ? '' : 's'} against the live model · see the <strong>Execution</strong> tab.`);
3432
+ if (!simulate) markFirstRunDone(app.id); // a real run completed → calm the beacon (app captured pre-await)
3218
3433
  } catch (e) {
3219
3434
  const msg = (e && e.message) ? e.message : String(e);
3220
3435
  if (cancelRequested) {
@@ -4116,6 +4331,12 @@
4116
4331
  : '';
4117
4332
  return `In floless app "${req.appId}", re-read & re-bake${where} per the floless-app-rebake skill: ${req.instruction}${snaps}`;
4118
4333
  }
4334
+ if (req.type === 'guide') {
4335
+ return `Walk me through the floless workflow "${req.appId}" — explain in plain English what it does and how to run it (set its inputs → Compile → ▶ Run). Use your floless-app-${req.appId} skill if one exists; otherwise read the workflow's own description and node notes from GET /api/app/${req.appId} and explain from those. This is a guided explanation — don't change anything.`;
4336
+ }
4337
+ if (req.type === 'new-workflow') {
4338
+ return `Help me build a brand-new floless workflow from scratch, step by step, with your floless-app-new-workflow skill. Ask me what I want it to do, then before writing any node confirm the agents it needs are installed (offer to install a missing one from the catalogue, or to report a not-yet-existing agent as an idea). Author the .flo node by node, verifying each step is correct (plain-English descriptions, validate, compile, a real run), and finish by installing → Compile → ▶ Run so I can see it work and approve.`;
4339
+ }
4119
4340
  return '';
4120
4341
  }
4121
4342
 
@@ -4126,6 +4347,11 @@
4126
4347
  tweak: 'floless-app-workflows',
4127
4348
  'ui-customize': 'floless-app-ui',
4128
4349
  rebake: 'floless-app-rebake',
4350
+ // The guided-tour ask is picked up by onboarding; the instruction body names the
4351
+ // per-app floless-app-<appId> skill for depth when one exists.
4352
+ guide: 'floless-app-onboarding',
4353
+ // Build-a-workflow-from-scratch: its own guide skill (interview → author → verify).
4354
+ 'new-workflow': 'floless-app-new-workflow',
4129
4355
  };
4130
4356
 
4131
4357
  // The COPIED form of a request: instructionFor() prefixed with a self-identifying
@@ -4220,7 +4446,7 @@
4220
4446
  return;
4221
4447
  }
4222
4448
  $list.innerHTML = pendingRequests.map((r) => {
4223
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : 'tweak';
4449
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
4224
4450
  const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
4225
4451
  const target = r.type === 'tweak' && r.nodeId
4226
4452
  ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
@@ -5034,6 +5260,7 @@
5034
5260
  try {
5035
5261
  const res = await api('/api/trigger-run', { method: 'POST', body: JSON.stringify({ id, inputs: currentInputs() }) });
5036
5262
  foregroundTrigger = { appId: id, sessionId: res.sessionId, firedCount: 0 };
5263
+ markFirstRunDone(id); // a real live session started → a streaming workflow's "first run" is no longer new
5037
5264
  syncRunControls(); // header ■ Stop run becomes reachable; Run disabled while live
5038
5265
  paintForegroundListening();
5039
5266
  appendNarration(`Live session started for <strong>${escapeHtml(id)}</strong> — the report updates on every event. Click <strong>■ Stop run</strong> (top right) to end it.`);
@@ -5718,6 +5945,25 @@
5718
5945
  if (e.key === 'Escape' && $reqModal && $reqModal.classList.contains('show')) $reqModal.classList.remove('show');
5719
5946
  });
5720
5947
 
5948
+ // Per-workflow onboarding beacon + guide panel + first-open spotlight.
5949
+ const $guideBeaconBtn = document.getElementById('guide-beacon');
5950
+ if ($guideBeaconBtn) $guideBeaconBtn.onclick = () => { hideSpotlight(); openGuide(); };
5951
+ const $guideModal = document.getElementById('guide-modal');
5952
+ const $guideGot = document.getElementById('guide-got-it');
5953
+ if ($guideGot) $guideGot.onclick = () => closeGuide();
5954
+ const $guideWalk = document.getElementById('guide-walk');
5955
+ if ($guideWalk) $guideWalk.onclick = () => requestGuide();
5956
+ if ($guideModal) onBackdropDismiss($guideModal, () => closeGuide());
5957
+ document.addEventListener('keydown', (e) => {
5958
+ if (e.key === 'Escape' && $guideModal && $guideModal.classList.contains('show')) closeGuide();
5959
+ });
5960
+ const $spotGot = document.getElementById('guide-spotlight-got');
5961
+ if ($spotGot) $spotGot.onclick = () => hideSpotlight();
5962
+ const $spotShow = document.getElementById('guide-spotlight-show');
5963
+ if ($spotShow) $spotShow.onclick = () => { hideSpotlight(); openGuide(); };
5964
+ const $spotlightEl = document.getElementById('guide-spotlight');
5965
+ if ($spotlightEl) $spotlightEl.onclick = (e) => { if (e.target === $spotlightEl) hideSpotlight(); };
5966
+
5721
5967
  // Pull all workspace data (agents/templates/requests + the apps list). Factored
5722
5968
  // out of bootWorkspace so the health poll can re-pull it after the server comes
5723
5969
  // back from a boot-time outage (setServerStatus → recovery).
@@ -58,6 +58,13 @@
58
58
  <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
59
59
  (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
60
60
  <span class="ctl-sep" aria-hidden="true"></span>
61
+ <!-- Per-workflow onboarding beacon. Pulses "✨ Start here" while this workflow is
62
+ new to the user (no first Run completed, not dismissed); calms to a quiet
63
+ "❔ Guide" after — always clickable for re-reference. Hidden until an app loads. -->
64
+ <button id="guide-beacon" class="guide-beacon" type="button" hidden aria-haspopup="dialog" data-tip="New here? See what this workflow does and how to run it.">
65
+ <span class="guide-beacon-ico" aria-hidden="true">✦</span>
66
+ <span class="guide-beacon-label">Start here</span>
67
+ </button>
61
68
  <span class="run-state" id="run-state" role="status" aria-live="polite"></span>
62
69
  <button id="compile-btn" data-tip="Compile + approve → freeze the .lock">⎙ Compile</button>
63
70
  <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>
@@ -269,6 +276,10 @@
269
276
  <span class="menu-label">Save inputs</span>
270
277
  <span class="menu-kbd">Ctrl+S</span>
271
278
  </button>
279
+ <button class="menu-item" data-action="new-workflow" role="menuitem">
280
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/></svg></span>
281
+ <span class="menu-label">New workflow…</span>
282
+ </button>
272
283
  <button class="menu-item" data-action="import" role="menuitem">
273
284
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0-4-4m4 4 4-4"/><path d="M3 17v2a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2"/></svg></span>
274
285
  <span class="menu-label">Import workflow…</span>
@@ -511,6 +522,37 @@
511
522
  </div>
512
523
  </div>
513
524
 
525
+ <!-- Per-workflow onboarding "Start here" guide. Surfaces the workflow's plain-English
526
+ description + a live "Set inputs → Compile → Run" checklist (ticks from real state),
527
+ and relays a "walk me through it" ask to the terminal AI. A styled modal — never a
528
+ native dialog; body (#guide-body) is rendered per-open in aware.js. -->
529
+ <div class="modal-backdrop" id="guide-modal">
530
+ <div class="modal guide" role="dialog" aria-modal="true" aria-labelledby="guide-title" tabindex="-1">
531
+ <div class="guide-eyebrow" id="guide-eyebrow">✦ Start here</div>
532
+ <div class="modal-title" id="guide-title">Workflow</div>
533
+ <div class="modal-sub">What this workflow does, and how to go through it.</div>
534
+ <div id="guide-body"></div>
535
+ <div class="modal-actions">
536
+ <button id="guide-got-it">Got it</button>
537
+ <button id="guide-walk" class="primary">Walk me through it →</button>
538
+ </div>
539
+ </div>
540
+ </div>
541
+
542
+ <!-- One-time first-open spotlight: dims the canvas and points at the "Start here"
543
+ beacon, then collapses into it. Shown once per workflow; Esc / click-away / either
544
+ button dismiss it. The beacon gets .spotlit so it sits above the dim. The bubble is
545
+ positioned next to the beacon at open time (aware.js). -->
546
+ <div id="guide-spotlight" class="guide-spotlight" hidden>
547
+ <div class="guide-spotlight-bubble" role="dialog" aria-modal="false" aria-label="New workflow tip" tabindex="-1">
548
+ <div class="guide-spotlight-text">New to this workflow? <strong>Start here</strong> — see what it does and how to run it.</div>
549
+ <div class="guide-spotlight-actions">
550
+ <button type="button" id="guide-spotlight-got" class="ghost">Got it</button>
551
+ <button type="button" id="guide-spotlight-show" class="primary">Show me</button>
552
+ </div>
553
+ </div>
554
+ </div>
555
+
514
556
  <!-- Routines — scheduled .flo runs. The list; the add/edit form is its own modal. -->
515
557
  <div class="modal-backdrop" id="routines-modal">
516
558
  <div class="modal routines">
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
+ import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
19
20
  import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
20
21
 
21
22
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
@@ -126,7 +127,7 @@ function init(canvas, theApi) {
126
127
  window.addEventListener('pointermove', onMove); // window so a drag keeps tracking off-canvas
127
128
  window.addEventListener('pointerup', onUp);
128
129
  canvas.addEventListener('pointermove', onHoverMove); // canvas-only: cursor + hover readout when idle
129
- canvas.addEventListener('dblclick', onDblClick); // Tekla "set view point": dbl-click sets the orbit/zoom pivot
130
+ canvas.addEventListener('dblclick', onDblClick); // "zoom to detail": dbl-click a part zooms to it (empty = no-op)
130
131
  window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
131
132
  ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
132
133
  initCube();
@@ -223,10 +224,98 @@ function placeMember(mesh, a, b, el) {
223
224
  mesh.position.copy(A).addScaledVector(dir, len / 2);
224
225
  }
225
226
 
227
+ // ---- connection parts (base-plate slice) — the new scene-element kinds the connection engine
228
+ // (server/steel-joints.ts) emits: a plate with holes, anchor rods, a weld bead. These are DERIVED
229
+ // geometry, not contract members, so buildFromScene marks them non-editable (excluded from picks).
230
+
231
+ // A flat plate (with punched holes) extruded along its normal, oriented by its in-plane axes.
232
+ function placePlate(mesh, el) {
233
+ const w = Math.max(el.width || 10, 1), d = Math.max(el.depth || 10, 1), t = Math.max(el.thickness || 5, 0.5);
234
+ const hw = w / 2, hd = d / 2;
235
+ const shape = new THREE.Shape();
236
+ shape.moveTo(-hw, -hd); shape.lineTo(hw, -hd); shape.lineTo(hw, hd); shape.lineTo(-hw, hd); shape.closePath();
237
+ for (const h of el.holes || []) { // bolts own the holes — punch one per fastener
238
+ const r = Math.max((h.d || 0) / 2, 0.1);
239
+ const hole = new THREE.Path(); hole.absarc(h.u || 0, h.v || 0, r, 0, Math.PI * 2, true);
240
+ shape.holes.push(hole);
241
+ }
242
+ const g = new THREE.ExtrudeGeometry(shape, { depth: t, bevelEnabled: false });
243
+ g.translate(0, 0, -t / 2); // centre the slab on the plate plane
244
+ mesh.geometry = g;
245
+ const u = V(...(el.uDir || [1, 0, 0])).normalize();
246
+ const v = V(...(el.vDir || [0, 1, 0])).normalize();
247
+ const n = new THREE.Vector3().crossVectors(u, v).normalize(); // extrude axis = plate normal
248
+ mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(u, v, n));
249
+ mesh.position.set(...(el.center || [0, 0, 0]));
250
+ }
251
+
252
+ // A cylinder between two points (anchor rod / bolt shank).
253
+ function placeBar(mesh, a, b, r, radial) {
254
+ const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
255
+ const dir = new THREE.Vector3().subVectors(B, A); const len = dir.length() || 1; dir.normalize();
256
+ mesh.geometry = new THREE.CylinderGeometry(r, r, len, radial || 16, 1, false); // axis = local +Y
257
+ mesh.quaternion.setFromUnitVectors(_YA, dir);
258
+ mesh.position.copy(A).addScaledVector(dir, len / 2);
259
+ }
260
+
261
+ // A fillet weld traced as a thin bead along its path — STRAIGHT segments (no CatmullRom rounding) so it
262
+ // hugs the actual edge. Path points are already world mm. `closed` (default true) wraps end→start (the
263
+ // base-plate column outline). `closed:false` = an OPEN polyline (the shear plate's straight edge weld) —
264
+ // must NOT wrap: a 2-point wrap doubles back into a cusp that degenerates the tube (an invisible weld).
265
+ function placeWeld(mesh, el) {
266
+ const pts = (el.path || []).map((p) => V(p[0], p[1], p[2]));
267
+ if (pts.length < 2) { mesh.geometry = new THREE.BufferGeometry(); return; }
268
+ const r = Math.max((el.leg || 6) * 0.6, 2); // enough girth to read as a fillet bead, not a wire ring
269
+ const closed = el.closed !== false; // default = a closed loop (back-compat with the base-plate weld)
270
+ const cp = new THREE.CurvePath();
271
+ const last = closed ? pts.length : pts.length - 1; // closed → wrap the final segment; open → stop one short
272
+ for (let i = 0; i < last; i++) { // skip any zero-length segment (avoids NaN tube frames)
273
+ const a = pts[i], b = pts[(i + 1) % pts.length];
274
+ if (a.distanceToSquared(b) > 1e-6) cp.add(new THREE.LineCurve3(a, b));
275
+ }
276
+ if (!cp.curves.length) { mesh.geometry = new THREE.BufferGeometry(); return; }
277
+ mesh.geometry = new THREE.TubeGeometry(cp, Math.max(pts.length * 6, 24), r, 8, false);
278
+ mesh.position.set(0, 0, 0); // geometry carries world coordinates
279
+ }
280
+
281
+ // A hex nut: a 6-sided prism swept from→to. acrossFlats (wrench size) → circumradius = AF/√3.
282
+ function placeNut(mesh, el) {
283
+ placeBar(mesh, el.from, el.to, Math.max((el.acrossFlats || 20) / Math.sqrt(3), 0.5), 6);
284
+ }
285
+
286
+ // A flat washer: an annulus (outer ⌀ with an inner hole) swept from→to along its axis.
287
+ function placeWasher(mesh, el) {
288
+ const A = V(el.from[0], el.from[1], el.from[2]), B = V(el.to[0], el.to[1], el.to[2]);
289
+ const dir = new THREE.Vector3().subVectors(B, A); const len = dir.length() || 1; dir.normalize();
290
+ const ro = Math.max((el.dOuter || 20) / 2, 0.5), ri = Math.min(Math.max((el.dInner || 10) / 2, 0.1), ro - 0.2);
291
+ const shape = new THREE.Shape(); shape.absarc(0, 0, ro, 0, Math.PI * 2, false);
292
+ const hole = new THREE.Path(); hole.absarc(0, 0, ri, 0, Math.PI * 2, true); shape.holes.push(hole);
293
+ mesh.geometry = new THREE.ExtrudeGeometry(shape, { depth: len, bevelEnabled: false, curveSegments: 24 });
294
+ mesh.quaternion.setFromUnitVectors(_ZA, dir); // shape-plane normal (local +Z) → washer axis
295
+ mesh.position.copy(A); // extrudes from `from` toward `to`
296
+ }
297
+
298
+ // Build a mesh for one scene element by kind: a member box (default) or a connection part. Returns
299
+ // false for an unknown kind so the caller skips it (forward-compatible with later part kinds).
300
+ function placeElement(mesh, el) {
301
+ switch (el.kind || 'box') {
302
+ case 'box': placeMember(mesh, el.from, el.to, el); return true;
303
+ case 'plate': placePlate(mesh, el); return true;
304
+ case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
305
+ case 'nut': placeNut(mesh, el); return true;
306
+ case 'washer': placeWasher(mesh, el); return true;
307
+ case 'weld': placeWeld(mesh, el); return true;
308
+ default: return false;
309
+ }
310
+ }
311
+
226
312
  function materialFor(groupKey) {
227
313
  if (!baseMat.has(groupKey)) {
228
314
  const col = groupColor.get(groupKey) || new THREE.Color(0x94a3b8);
229
- baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: 0.1, roughness: 0.75 }));
315
+ // Fasteners (anchor rods / bolts / nuts / washers) read more metallic (zinc-plated); plates/welds/members stay matte.
316
+ const metallic = groupKey === 'anchor' || groupKey === 'bolt' || groupKey === 'nut' || groupKey === 'washer';
317
+ const metal = metallic ? 0.5 : 0.1, rough = metallic ? 0.4 : 0.75;
318
+ baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: metal, roughness: rough }));
230
319
  }
231
320
  return baseMat.get(groupKey);
232
321
  }
@@ -240,6 +329,34 @@ function clearRoot() {
240
329
  meshById.clear();
241
330
  }
242
331
 
332
+ // Subtract each cope (a `cut` part) from a member's solid via CSG — a real coped beam end. Cut boxes are
333
+ // world-space (center/uDir/vDir/width/depth/thickness); we transform them into the member's LOCAL frame so
334
+ // the result keeps the member's placement (drag/select still work), then subtract. On any CSG failure the
335
+ // member is left uncoped rather than breaking the whole scene.
336
+ const _csgEval = new Evaluator();
337
+ _csgEval.useGroups = false; // one solid out → the member's single material applies
338
+ function applyCopes(mesh, cuts) {
339
+ try {
340
+ mesh.updateMatrix(); // local TRS (root is identity → local == world mm)
341
+ const invMember = mesh.matrix.clone().invert();
342
+ let result = new Brush(mesh.geometry); result.updateMatrixWorld(true); // member-local, identity
343
+ for (const cut of cuts) {
344
+ const pad = 0.6; // over-cut so coplanar faces don't leave slivers
345
+ const g = new THREE.BoxGeometry(Math.max(cut.width || 1, 1) + pad, Math.max(cut.depth || 1, 1) + pad, Math.max(cut.thickness || 1, 1) + pad);
346
+ const u = V(...(cut.uDir || [1, 0, 0])).normalize(), vv = V(...(cut.vDir || [0, 1, 0])).normalize();
347
+ const n = new THREE.Vector3().crossVectors(u, vv).normalize();
348
+ const world = new THREE.Matrix4().makeBasis(u, vv, n); world.setPosition(cut.center[0], cut.center[1], cut.center[2]);
349
+ const b = new Brush(g);
350
+ invMember.clone().multiply(world).decompose(b.position, b.quaternion, b.scale); // cut → member-local
351
+ b.updateMatrixWorld(true);
352
+ result = _csgEval.evaluate(result, b, SUBTRACTION);
353
+ g.dispose();
354
+ }
355
+ mesh.geometry.dispose();
356
+ mesh.geometry = result.geometry; // member-local solid carrying the notch(es); mesh keeps its placement
357
+ } catch (e) { console.warn('[steel-3d] cope CSG failed; member left uncoped', mesh.userData && mesh.userData.id, e); }
358
+ }
359
+
243
360
  function buildFromScene(sc) {
244
361
  clearRoot();
245
362
  for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
@@ -247,10 +364,22 @@ function buildFromScene(sc) {
247
364
  sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
248
365
  for (const g of sceneGroups) groupColor.set(g.key, new THREE.Color(g.color));
249
366
  const box = new THREE.Box3();
367
+ // Copes (`cut` parts) are SUBTRACTIVE — they notch their target member's mesh, they don't render as
368
+ // standalone parts. Collect them by target member id first, then subtract once the member mesh is built.
369
+ const cutsByMember = new Map();
370
+ for (const el of sc.elements || []) {
371
+ if (el && el.kind === 'cut' && el.member) {
372
+ if (!cutsByMember.has(el.member)) cutsByMember.set(el.member, []);
373
+ cutsByMember.get(el.member).push(el);
374
+ }
375
+ }
250
376
  for (const el of sc.elements || []) {
251
377
  const mesh = new THREE.Mesh(undefined, materialFor(el.group));
252
- placeMember(mesh, el.from, el.to, el); // real cross-section (I / channel / angle / tube / box) from the profile
378
+ if (!placeElement(mesh, el)) continue; // unknown kind → skip (a `cut` is one of these: applied below, never a standalone mesh)
379
+ const memberCuts = cutsByMember.get(el.id);
380
+ if (memberCuts && memberCuts.length) applyCopes(mesh, memberCuts); // notch a coped member end
253
381
  mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
382
+ mesh.userData.derived = !!(el.kind && el.kind !== 'box'); // connection parts: rendered, not member-editable
254
383
  root.add(mesh); meshById.set(el.id, mesh);
255
384
  box.expandByObject(mesh);
256
385
  }
@@ -462,14 +591,25 @@ function reflectDimBar() {
462
591
  reflectDimAxisBar();
463
592
  }
464
593
  function reflectDimAxisBar() { document.querySelectorAll('#m3dDimAxis button').forEach((x) => x.classList.toggle('on', x.dataset.d3axis === dimAxis3d)); }
465
- // Tekla "set view point": double-click a membermake that point the orbit/zoom pivot.
594
+ // "Zoom to detail": double-click a partzoom to a tight region around the click point so a connection
595
+ // fills the view (members AND their derived connection parts are valid targets), keeping the current view
596
+ // direction. Empty space → no-op (use the Fit button / Home for fit-all, so a stray double-click can't
597
+ // teleport the camera). Replaces the old set-orbit-pivot — zoom-to sets the pivot anyway, and
598
+ // zoomToCursor already lets the wheel zoom toward the cursor.
466
599
  function onDblClick(e) {
467
600
  camera.updateMatrixWorld(); root.updateMatrixWorld(true);
468
601
  const rect = canvasEl.getBoundingClientRect();
469
602
  const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
470
603
  raycaster.setFromCamera(ndc, camera);
471
- const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
472
- if (hits.length) { controls.target.copy(hits[0].point); controls.update(); }
604
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false); // incl. connection parts
605
+ if (!hits.length) return; // empty space → no-op (Fit / Home fit-all; avoids an accidental camera teleport)
606
+ const p = hits[0].point, mesh = hits[0].object;
607
+ if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
608
+ const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
609
+ const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
610
+ const half = Math.min(Math.max(sect * 3, 300), 2500); // a detail-scale zoom box (clamped sane)
611
+ const viewDir = camera.position.clone().sub(controls.target).normalize(); // keep the current view orientation
612
+ fitCamera(new THREE.Box3().setFromCenterAndSize(p, new THREE.Vector3(half * 2, half * 2, half * 2)), viewDir.lengthSq() > 0.5 ? viewDir : undefined);
473
613
  }
474
614
 
475
615
  function setSelection(ids) {
@@ -636,7 +776,7 @@ function pickAt(cx, cy) {
636
776
  const rect = canvasEl.getBoundingClientRect();
637
777
  const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
638
778
  raycaster.setFromCamera(ndc, camera);
639
- const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
779
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible && !m.userData.derived), false);
640
780
  lastPick = hits.length ? hits[0].object.userData.id : null;
641
781
  return lastPick;
642
782
  }
@@ -855,7 +995,7 @@ function membersInRect(x0, y0, x1, y1) {
855
995
  const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
856
996
  const out = [];
857
997
  for (const [id, m] of meshById) {
858
- if (!m.visible) continue;
998
+ if (!m.visible || m.userData.derived) continue; // derived connection parts aren't member-selectable (mirrors pickAt; dbl-click-zoom deliberately includes them)
859
999
  const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
860
1000
  const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
861
1001
  if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(id);
@@ -959,7 +1099,7 @@ function updateStatusChip() {
959
1099
  const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
960
1100
  if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
961
1101
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
962
- else txt = 'Drag a member to move · ends to stretch · Alt+drag elevation · right-drag orbits';
1102
+ else txt = 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
963
1103
  hoverChip.textContent = txt;
964
1104
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
965
1105
  hoverChip.style.top = (rect.bottom - 30) + 'px';