@floless/app 0.54.1 → 0.55.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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.54.1" : void 0,
52859
+ define: true ? "0.55.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.54.1" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.55.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // workflow-update.ts
@@ -63876,6 +63876,13 @@ async function startServer() {
63876
63876
  broadcast({ type: "request-added", request });
63877
63877
  return { ok: true, request };
63878
63878
  });
63879
+ app.post("/api/guide", async (req, reply) => {
63880
+ const { appId } = req.body ?? {};
63881
+ if (!appId) return reply.status(400).send({ ok: false, error: "appId required" });
63882
+ const request = addRequest({ type: "guide", appId });
63883
+ broadcast({ type: "request-added", request });
63884
+ return { ok: true, request };
63885
+ });
63879
63886
  app.delete("/api/requests/:id", async (req, reply) => {
63880
63887
  if (!deleteRequest(req.params.id)) return reply.status(404).send({ ok: false, error: "request not found" });
63881
63888
  broadcast({ type: "requests-changed" });
package/dist/web/app.css CHANGED
@@ -479,6 +479,152 @@
479
479
  .onb-tour-btn:disabled { cursor: default; opacity: 0.85; box-shadow: none; }
480
480
  .onb-tour-ico { font-size: 11px; opacity: 0.7; margin-left: 6px; }
481
481
 
482
+ /* ===== Per-workflow onboarding beacon ("Start here") ===== */
483
+ /* A header pill in the run cluster. Calm by default (accent-soft, like the
484
+ wf-update pill); pulses while the open workflow is new to the user. Accent —
485
+ NOT warn — because it's positive/new, and warn is already the drift colour. */
486
+ .guide-beacon {
487
+ display: inline-flex;
488
+ align-items: center;
489
+ gap: 5px;
490
+ border: 1px solid var(--accent-dim);
491
+ background: var(--accent-soft);
492
+ color: var(--accent-bright);
493
+ font-family: var(--ui);
494
+ font-size: 11px;
495
+ letter-spacing: 0.02em;
496
+ padding: 3px 11px;
497
+ border-radius: 999px;
498
+ cursor: pointer;
499
+ white-space: nowrap;
500
+ flex-shrink: 0; /* the workflow picker shrinks first; the beacon never squashes */
501
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
502
+ }
503
+ .guide-beacon[hidden] { display: none; }
504
+ .guide-beacon:hover { border-color: var(--accent); color: var(--accent); }
505
+ .guide-beacon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
506
+ .guide-beacon-ico { font-size: 11px; line-height: 1; }
507
+ /* "New to you": a slow box-shadow bloom (GPU-composited, no layout repaint) reading
508
+ as a calm invitation, not an alarm. Same motion dialect as the rest of the app. */
509
+ .guide-beacon.is-new {
510
+ border-color: var(--accent);
511
+ color: var(--accent-bright);
512
+ animation: guide-pulse 2.8s ease-in-out infinite;
513
+ }
514
+ @keyframes guide-pulse {
515
+ 0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
516
+ 50% { box-shadow: 0 0 0 6px rgba(74, 158, 255, 0); }
517
+ }
518
+ @media (prefers-reduced-motion: reduce) {
519
+ /* No pulse — a steady ring (shape + contrast, not colour alone) marks "new". */
520
+ .guide-beacon.is-new { animation: none; box-shadow: 0 0 0 1px var(--accent-dim); }
521
+ }
522
+ /* Spotlit (first-open): raised above the dim, opaque, ringed. The header creates no
523
+ stacking context, so z-index here lifts it above the fixed spotlight overlay. */
524
+ .guide-beacon.spotlit {
525
+ position: relative;
526
+ z-index: 95;
527
+ animation: none;
528
+ background: var(--surface-3);
529
+ border-color: var(--accent);
530
+ color: var(--accent-bright);
531
+ box-shadow: 0 0 0 3px var(--accent-glow), 0 0 20px 4px var(--accent-glow);
532
+ }
533
+
534
+ /* ===== First-open spotlight ===== */
535
+ .guide-spotlight {
536
+ position: fixed;
537
+ inset: 0;
538
+ z-index: 90; /* below modals (100), above the page; the beacon (.spotlit, 95) sits over it */
539
+ background: rgba(7, 10, 15, 0.72);
540
+ backdrop-filter: blur(2px);
541
+ -webkit-backdrop-filter: blur(2px);
542
+ display: none;
543
+ }
544
+ .guide-spotlight:not([hidden]) { display: block; animation: fade-in 0.18s ease-out; }
545
+ .guide-spotlight-bubble {
546
+ position: fixed;
547
+ z-index: 91;
548
+ max-width: 280px;
549
+ background: var(--surface);
550
+ border: 1px solid var(--accent-dim);
551
+ border-radius: 8px;
552
+ padding: 12px 14px;
553
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
554
+ }
555
+ .guide-spotlight-text { font-size: 12.5px; line-height: 1.5; color: var(--text); }
556
+ .guide-spotlight-text strong { color: var(--accent-bright); }
557
+ .guide-spotlight-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 11px; }
558
+ .guide-spotlight-actions button { padding: 5px 13px; font-size: 12px; }
559
+ .guide-spotlight-actions .ghost { background: var(--surface-2); color: var(--text-muted); }
560
+ .guide-spotlight-actions .ghost:hover { color: var(--text); border-color: var(--accent-dim); }
561
+ .guide-spotlight-actions .primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
562
+ .guide-spotlight-actions .primary:hover { background: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow); }
563
+
564
+ /* ===== Guide panel (modal) ===== */
565
+ .modal.guide {
566
+ width: 520px;
567
+ max-width: 92vw;
568
+ max-height: 82vh;
569
+ display: flex;
570
+ flex-direction: column;
571
+ }
572
+ .guide-eyebrow {
573
+ font-size: 10px;
574
+ text-transform: uppercase;
575
+ letter-spacing: 0.16em;
576
+ color: var(--accent-bright);
577
+ margin-bottom: 6px;
578
+ }
579
+ /* The body scrolls between the pinned title and the pinned actions; theme its
580
+ scrollbar so no native white bar leaks against the dark surface. */
581
+ #guide-body {
582
+ overflow-y: auto;
583
+ flex: 1 1 auto;
584
+ min-height: 0;
585
+ margin-top: 2px;
586
+ scrollbar-width: thin;
587
+ scrollbar-color: var(--border-strong) transparent;
588
+ }
589
+ #guide-body::-webkit-scrollbar { width: 7px; }
590
+ #guide-body::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 7px; }
591
+ #guide-body::-webkit-scrollbar-track { background: transparent; }
592
+ .guide-section-label {
593
+ font-size: 10px;
594
+ text-transform: uppercase;
595
+ letter-spacing: 0.14em;
596
+ color: var(--text-muted);
597
+ margin: 14px 0 7px;
598
+ }
599
+ .guide-section-label:first-child { margin-top: 2px; }
600
+ .guide-desc { font-size: 12.5px; line-height: 1.55; color: var(--text); }
601
+ .guide-desc p { margin: 0 0 8px; }
602
+ .guide-desc p:last-child { margin-bottom: 0; }
603
+ .guide-desc code { font-family: var(--mono); background: var(--surface-2); padding: 1px 5px; border-radius: 3px; color: var(--text-muted); font-size: 11px; }
604
+ .guide-desc-empty { color: var(--text-muted); font-style: italic; }
605
+ .guide-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
606
+ .guide-step { display: flex; gap: 10px; align-items: baseline; }
607
+ .guide-box { font-family: var(--mono); font-size: 13px; min-width: 16px; color: var(--text-dim); line-height: 1.3; }
608
+ .guide-step-label { font-size: 12.5px; color: var(--text); }
609
+ .guide-step-note { font-size: 11px; color: var(--text-dim); font-style: italic; }
610
+ /* Done: ✓ in ok-green + struck/dimmed label, so the current step stands out on
611
+ shape + contrast, not colour alone. Current: → in accent + bold label. */
612
+ .guide-step.done .guide-box { color: var(--ok); }
613
+ .guide-step.done .guide-step-label { color: var(--text-dim); text-decoration: line-through; text-decoration-color: var(--text-dim); }
614
+ .guide-step.current .guide-box { color: var(--accent); }
615
+ .guide-step.current .guide-step-label { color: var(--text); font-weight: 600; }
616
+ .guide-walk-hint {
617
+ font-size: 11px;
618
+ line-height: 1.5;
619
+ color: var(--text-muted);
620
+ margin-top: 14px;
621
+ padding-top: 12px;
622
+ border-top: 1px solid var(--border);
623
+ }
624
+ .guide-walk-hint strong { color: var(--text); font-weight: 600; }
625
+ .modal.guide .modal-actions { margin-top: 16px; }
626
+ .modal.guide .modal-actions #guide-got-it { margin-right: auto; }
627
+
482
628
  .agent-card {
483
629
  background: var(--surface);
484
630
  border: 1px solid var(--border-strong);
package/dist/web/aware.js CHANGED
@@ -531,11 +531,193 @@
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
+ // ── First-open spotlight (one-time per workflow) ────────────────────────────
673
+ // Dims the canvas, raises the beacon, and points a small caption at it; Esc /
674
+ // click-away / either button dismiss it for good. Slice 2 of the onboarding beacon.
675
+ let _spotlightKey = null;
676
+ function maybeSpotlight(app) {
677
+ if (!app) return;
678
+ const g = guideFor(app.id);
679
+ if (g.spotlightShown || g.dismissed || g.firstRunDone) return;
680
+ if (document.querySelector('.modal-backdrop.show')) return; // don't pile on a dialog
681
+ if ($contractEditor && !$contractEditor.hidden) return;
682
+ setTimeout(() => {
683
+ if (currentId !== app.id) return; // switched away during the delay
684
+ if (guideFor(app.id).spotlightShown) return;
685
+ if (document.querySelector('.modal-backdrop.show')) return;
686
+ showSpotlight(app);
687
+ }, 700);
688
+ }
689
+ function showSpotlight(app) {
690
+ const $sp = document.getElementById('guide-spotlight');
691
+ const $b = document.getElementById('guide-beacon');
692
+ if (!$sp || !$b || $b.hidden) return;
693
+ setGuide(app.id, { spotlightShown: true });
694
+ $b.classList.add('spotlit');
695
+ $sp.hidden = false;
696
+ // Anchor the caption bubble just under the beacon, right-aligned to it.
697
+ const bubble = $sp.querySelector('.guide-spotlight-bubble');
698
+ if (bubble) {
699
+ const r = $b.getBoundingClientRect();
700
+ bubble.style.top = (r.bottom + 10) + 'px';
701
+ bubble.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
702
+ }
703
+ _spotlightKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); hideSpotlight(); } };
704
+ document.addEventListener('keydown', _spotlightKey);
705
+ if (bubble) { try { bubble.focus(); } catch { /* */ } }
706
+ }
707
+ function hideSpotlight() {
708
+ const $sp = document.getElementById('guide-spotlight');
709
+ const $b = document.getElementById('guide-beacon');
710
+ if ($sp) $sp.hidden = true;
711
+ if ($b) $b.classList.remove('spotlit');
712
+ if (_spotlightKey) { document.removeEventListener('keydown', _spotlightKey); _spotlightKey = null; }
713
+ }
714
+
534
715
  // ── load / select / compile / run ───────────────────────────────────────────
535
716
 
536
717
  let _wfUpdates = []; // workflow updates available, keyed by app id — polled by refreshWorkflowUpdates() below
537
718
 
538
719
  async function loadApp(id) {
720
+ hideSpotlight(); // a spotlight pointing at the previous app's beacon must clear on switch
539
721
  const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
540
722
  apps.set(id, app);
541
723
  currentId = id;
@@ -555,8 +737,10 @@
555
737
  seedAppInputs(app);
556
738
  markSpecialNodes();
557
739
  paintGate(app);
740
+ paintBeacon(app); // pulse "Start here" while this workflow is new to the user
558
741
  refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
559
742
  renderWfUpdatePill(); // show the "↑ Update to vX" pill when THIS app has a newer published version
743
+ maybeSpotlight(app); // one-time first-open spotlight pointing at the beacon (slice 2)
560
744
  }
561
745
 
562
746
  async function loadApps() {
@@ -574,6 +758,8 @@
574
758
  if ($menuBakeItem) $menuBakeItem.disabled = true;
575
759
  if ($wfTrigger) $wfTrigger.disabled = true;
576
760
  setComboTriggerLabel('no workflows installed');
761
+ paintBeacon(null); // no workflow open → hide the per-workflow beacon
762
+ hideSpotlight();
577
763
  renderCanvasPlaceholder('empty');
578
764
  return;
579
765
  }
@@ -814,8 +1000,18 @@
814
1000
  if (v != null) localStorage.setItem(lsInputsKey(toId), v);
815
1001
  } catch { /* private mode / quota */ }
816
1002
  }
1003
+ // A rename is the SAME workflow under a new id → carry its onboarding-seen state so it
1004
+ // doesn't re-pulse "Start here" / re-show the spotlight. (Not done on duplicate: a copy
1005
+ // is a genuinely new workflow that the user hasn't run, so it should start new.)
1006
+ function migrateGuideLS(fromId, toId) {
1007
+ try {
1008
+ const all = guideStateAll();
1009
+ if (all[fromId]) { all[toId] = all[fromId]; localStorage.setItem(LS_GUIDE, JSON.stringify(all)); }
1010
+ } catch { /* private mode / quota */ }
1011
+ }
817
1012
  function dropClientAppState(id) {
818
1013
  try { localStorage.removeItem(lsInputsKey(id)); } catch { /* ignore */ }
1014
+ try { const all = guideStateAll(); if (all[id]) { delete all[id]; localStorage.setItem(LS_GUIDE, JSON.stringify(all)); } } catch { /* ignore */ }
819
1015
  appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
820
1016
  }
821
1017
 
@@ -843,6 +1039,7 @@
843
1039
  // so a rename never silently drops the user's inputs (#85 review). loadApp() only seeds
844
1040
  // inputs when the id is absent, so pre-setting them here preserves unsaved edits.
845
1041
  migrateInputsLS(oldId, r.id);
1042
+ migrateGuideLS(oldId, r.id); // carry onboarding-seen state so the renamed workflow doesn't re-pulse
846
1043
  if (appInputValues.has(oldId)) appInputValues.set(r.id, appInputValues.get(oldId));
847
1044
  if (dirtyBaseline.has(oldId)) dirtyBaseline.set(r.id, dirtyBaseline.get(oldId));
848
1045
  const wasOpen = currentId === oldId;
@@ -1767,6 +1964,7 @@
1767
1964
  $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
1965
  }
1769
1966
  paintReport(html, interactive);
1967
+ 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
1968
  lastReportByApp.set(currentId, { nodeId, label: inputBadge, html, interactive });
1771
1969
  $reportModal.dataset.html = '1';
1772
1970
  appendNarration(`Rendered the ${interactive ? '3D view' : 'report'} from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
@@ -3215,6 +3413,7 @@
3215
3413
  appendNarration(simulate
3216
3414
  ? `Simulated run complete · ${steps} node${steps === 1 ? '' : 's'} stubbed (no host needed) · see the <strong>Execution</strong> tab.`
3217
3415
  : `Run complete · ${steps} node${steps === 1 ? '' : 's'} against the live model · see the <strong>Execution</strong> tab.`);
3416
+ if (!simulate) markFirstRunDone(app.id); // a real run completed → calm the beacon (app captured pre-await)
3218
3417
  } catch (e) {
3219
3418
  const msg = (e && e.message) ? e.message : String(e);
3220
3419
  if (cancelRequested) {
@@ -4116,6 +4315,9 @@
4116
4315
  : '';
4117
4316
  return `In floless app "${req.appId}", re-read & re-bake${where} per the floless-app-rebake skill: ${req.instruction}${snaps}`;
4118
4317
  }
4318
+ if (req.type === 'guide') {
4319
+ 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.`;
4320
+ }
4119
4321
  return '';
4120
4322
  }
4121
4323
 
@@ -4126,6 +4328,9 @@
4126
4328
  tweak: 'floless-app-workflows',
4127
4329
  'ui-customize': 'floless-app-ui',
4128
4330
  rebake: 'floless-app-rebake',
4331
+ // The guided-tour ask is picked up by onboarding; the instruction body names the
4332
+ // per-app floless-app-<appId> skill for depth when one exists.
4333
+ guide: 'floless-app-onboarding',
4129
4334
  };
4130
4335
 
4131
4336
  // The COPIED form of a request: instructionFor() prefixed with a self-identifying
@@ -4220,7 +4425,7 @@
4220
4425
  return;
4221
4426
  }
4222
4427
  $list.innerHTML = pendingRequests.map((r) => {
4223
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : 'tweak';
4428
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : 'tweak';
4224
4429
  const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
4225
4430
  const target = r.type === 'tweak' && r.nodeId
4226
4431
  ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
@@ -5034,6 +5239,7 @@
5034
5239
  try {
5035
5240
  const res = await api('/api/trigger-run', { method: 'POST', body: JSON.stringify({ id, inputs: currentInputs() }) });
5036
5241
  foregroundTrigger = { appId: id, sessionId: res.sessionId, firedCount: 0 };
5242
+ markFirstRunDone(id); // a real live session started → a streaming workflow's "first run" is no longer new
5037
5243
  syncRunControls(); // header ■ Stop run becomes reachable; Run disabled while live
5038
5244
  paintForegroundListening();
5039
5245
  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 +5924,25 @@
5718
5924
  if (e.key === 'Escape' && $reqModal && $reqModal.classList.contains('show')) $reqModal.classList.remove('show');
5719
5925
  });
5720
5926
 
5927
+ // Per-workflow onboarding beacon + guide panel + first-open spotlight.
5928
+ const $guideBeaconBtn = document.getElementById('guide-beacon');
5929
+ if ($guideBeaconBtn) $guideBeaconBtn.onclick = () => { hideSpotlight(); openGuide(); };
5930
+ const $guideModal = document.getElementById('guide-modal');
5931
+ const $guideGot = document.getElementById('guide-got-it');
5932
+ if ($guideGot) $guideGot.onclick = () => closeGuide();
5933
+ const $guideWalk = document.getElementById('guide-walk');
5934
+ if ($guideWalk) $guideWalk.onclick = () => requestGuide();
5935
+ if ($guideModal) onBackdropDismiss($guideModal, () => closeGuide());
5936
+ document.addEventListener('keydown', (e) => {
5937
+ if (e.key === 'Escape' && $guideModal && $guideModal.classList.contains('show')) closeGuide();
5938
+ });
5939
+ const $spotGot = document.getElementById('guide-spotlight-got');
5940
+ if ($spotGot) $spotGot.onclick = () => hideSpotlight();
5941
+ const $spotShow = document.getElementById('guide-spotlight-show');
5942
+ if ($spotShow) $spotShow.onclick = () => { hideSpotlight(); openGuide(); };
5943
+ const $spotlightEl = document.getElementById('guide-spotlight');
5944
+ if ($spotlightEl) $spotlightEl.onclick = (e) => { if (e.target === $spotlightEl) hideSpotlight(); };
5945
+
5721
5946
  // Pull all workspace data (agents/templates/requests + the apps list). Factored
5722
5947
  // out of bootWorkspace so the health poll can re-pull it after the server comes
5723
5948
  // 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>
@@ -511,6 +518,37 @@
511
518
  </div>
512
519
  </div>
513
520
 
521
+ <!-- Per-workflow onboarding "Start here" guide. Surfaces the workflow's plain-English
522
+ description + a live "Set inputs → Compile → Run" checklist (ticks from real state),
523
+ and relays a "walk me through it" ask to the terminal AI. A styled modal — never a
524
+ native dialog; body (#guide-body) is rendered per-open in aware.js. -->
525
+ <div class="modal-backdrop" id="guide-modal">
526
+ <div class="modal guide" role="dialog" aria-modal="true" aria-labelledby="guide-title" tabindex="-1">
527
+ <div class="guide-eyebrow" id="guide-eyebrow">✦ Start here</div>
528
+ <div class="modal-title" id="guide-title">Workflow</div>
529
+ <div class="modal-sub">What this workflow does, and how to go through it.</div>
530
+ <div id="guide-body"></div>
531
+ <div class="modal-actions">
532
+ <button id="guide-got-it">Got it</button>
533
+ <button id="guide-walk" class="primary">Walk me through it →</button>
534
+ </div>
535
+ </div>
536
+ </div>
537
+
538
+ <!-- One-time first-open spotlight: dims the canvas and points at the "Start here"
539
+ beacon, then collapses into it. Shown once per workflow; Esc / click-away / either
540
+ button dismiss it. The beacon gets .spotlit so it sits above the dim. The bubble is
541
+ positioned next to the beacon at open time (aware.js). -->
542
+ <div id="guide-spotlight" class="guide-spotlight" hidden>
543
+ <div class="guide-spotlight-bubble" role="dialog" aria-modal="false" aria-label="New workflow tip" tabindex="-1">
544
+ <div class="guide-spotlight-text">New to this workflow? <strong>Start here</strong> — see what it does and how to run it.</div>
545
+ <div class="guide-spotlight-actions">
546
+ <button type="button" id="guide-spotlight-got" class="ghost">Got it</button>
547
+ <button type="button" id="guide-spotlight-show" class="primary">Show me</button>
548
+ </div>
549
+ </div>
550
+ </div>
551
+
514
552
  <!-- Routines — scheduled .flo runs. The list; the add/edit form is its own modal. -->
515
553
  <div class="modal-backdrop" id="routines-modal">
516
554
  <div class="modal routines">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.54.1",
3
+ "version": "0.55.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": {