From 4f8f150f0f2eabc39a67326c224f7e03eefe68bf Mon Sep 17 00:00:00 2001 From: Ashu Date: Fri, 24 Jul 2026 15:47:55 +0530 Subject: [PATCH] feat: add first-run guided tour to the generated report, with entry-node pulse Every generated WORKFLOW.html now launches a spotlight walkthrough the first time it's opened, dimming the page and stepping through each section in turn (stat row, derivation, codebase graph, architecture, flows, concurrency, bottlenecks, theme toggle). Steps for sections a project doesn't have (no detected routes, no graph) are skipped automatically at runtime. Replayable anytime via a new "?" button next to the theme toggle; the tour is otherwise suppressed after first dismissal via localStorage. The codebase graph's entry node also gets a radar-ping pulse/glow the first time the graph is actually visible and clickable, to make it read as "start here." The pulse is gated on the guided tour not being active -- including a timing race where the graph's IntersectionObserver fires before the tour's auto-launch delay elapses -- so it never plays out during the window when the tour overlay would block the click it's inviting. Ring/glow color is theme-aware so it reads in both light and dark mode. --- INSTALL.md | 2 + README.md | 1 + SKILL.md | 5 + workflow_generator_mcp/analyze.py | 241 ++++++++++++++++++++++++++++-- 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 516afb4..ff013d8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -225,3 +225,5 @@ Every generated `WORKFLOW.html` includes: 6. **Codebase dependency graph** — force-directed module/import graph across all detected languages; click a node to isolate its neighbors. Import-direction edges by default; pass `--access-log` to weight HTTP-entry edges with real observed request counts instead +7. **Guided tour** — spotlight walkthrough shown automatically on first open, replayable via the + `?` button; steps for sections a project doesn't have (no routes, no graph) are skipped diff --git a/README.md b/README.md index bd7126e..f793bd2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Every generated page contains: | **Concurrency table** | Every layer: model · ceiling · limiting factor | | **Bottleneck analysis** | Ranked CRITICAL → LOW with mitigation notes | | **Codebase dependency graph** | Force-directed module/import graph — click a node to isolate its neighbors, hover for file details. Import-direction edges are clearly distinguished from real observed traffic (see below) | +| **Guided tour** | Spotlight walkthrough of every section, shown automatically the first time a report is opened; replay anytime with the `?` button | ### Codebase dependency graph diff --git a/SKILL.md b/SKILL.md index 4ad9cb5..9c16782 100644 --- a/SKILL.md +++ b/SKILL.md @@ -128,6 +128,11 @@ The generated `WORKFLOW.html` always contains all of the following: HTTP-entry edges are weighted with real observed request counts — the graph is explicit about which kind of edge is which so it's never mistaken for a traced request path. +7. **Guided tour** — a spotlight walkthrough that dims the page and steps through each section + in turn (Back/Next/Skip), shown automatically the first time a generated report is opened. + Steps for sections that don't apply to a given project (e.g. no detected routes, no graph) + are skipped automatically. Replayable anytime via the `?` button next to the theme toggle. + ## What the analyzer detects ### Workers & replicas diff --git a/workflow_generator_mcp/analyze.py b/workflow_generator_mcp/analyze.py index 693a0e3..af1fb47 100644 --- a/workflow_generator_mcp/analyze.py +++ b/workflow_generator_mcp/analyze.py @@ -1090,7 +1090,7 @@ def infer_flows(analysis: dict) -> list: @media(prefers-reduced-motion:no-preference){ .arrow-dot{animation:arrowFlow var(--t,4s) linear infinite; animation-delay:calc(var(--i,0)/var(--n,1)*var(--t,4s))} -.arch-boxes{animation:rowPulse var(--t,4s) ease-in-out infinite; +.arch-boxes{animation:rowPulse var(--t,10s) ease-in-out infinite; animation-delay:calc(var(--i,0)/var(--n,1)*var(--t,4s))} .step-num::after{animation:stepFlow var(--t,3s) linear infinite; animation-delay:calc(var(--i,0)/var(--n,1)*var(--t,3s))} @@ -1108,6 +1108,31 @@ def infer_flows(analysis: dict) -> list: .step-num::after{content:'';position:absolute;left:50%;top:-14px;width:5px;height:5px; border-radius:50%;background:currentColor;opacity:0;transform:translateX(-50%)} @keyframes stepFlow{0%{opacity:0;top:-14px}8%{opacity:1}92%{top:0;opacity:1}100%{opacity:0}} +/* Guided tour */ +.tour-overlay{position:fixed;inset:0;z-index:30;background:transparent} +.tour-spot{position:absolute;z-index:31;border-radius:12px;pointer-events:none; +box-shadow:0 0 0 9999px rgba(0,0,0,.6);transition:top .35s ease,left .35s ease,width .35s ease,height .35s ease} +:root[data-theme="light"] .tour-spot{box-shadow:0 0 0 9999px rgba(15,23,42,.45)} +.tour-card{position:absolute;z-index:32;width:320px;max-width:calc(100vw - 32px); +background:var(--bg3);border:1px solid var(--border2);border-radius:12px;padding:16px 18px; +box-shadow:0 8px 30px rgba(0,0,0,.4);transition:top .35s ease,left .35s ease;font-family:var(--sans)} +.tour-card .tour-step-count{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:8px} +.tour-card .tour-title{font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px} +.tour-card .tour-body{font-size:12.5px;line-height:1.55;color:var(--muted)} +.tour-card .tour-close{position:absolute;top:10px;right:12px;cursor:pointer;color:var(--muted); +font-size:16px;line-height:1;background:none;border:none;font-family:var(--sans)} +.tour-card .tour-close:hover{color:var(--text)} +.tour-card .tour-controls{display:flex;align-items:center;justify-content:space-between;margin-top:14px} +.tour-card .tour-skip{background:none;border:none;color:var(--muted);font-size:11.5px;cursor:pointer; +font-family:var(--sans);padding:0;text-decoration:underline} +.tour-card .tour-skip:hover{color:var(--text)} +.tour-card .tour-nav{display:flex;gap:8px} +.tour-card .tour-btn{background:var(--bg2);border:1px solid var(--border);border-radius:6px; +padding:6px 14px;font-size:11.5px;color:var(--text);cursor:pointer;font-family:var(--sans)} +.tour-card .tour-btn:hover{border-color:var(--border2)} +.tour-card .tour-btn.primary{background:#3b82f6;border-color:#3b82f6;color:#fff} +.tour-card .tour-btn.primary:hover{background:#2563eb;border-color:#2563eb} +.tour-card .tour-btn:disabled{opacity:.4;cursor:default} """ EDGE_JS = """ @@ -1460,15 +1485,38 @@ def infer_flows(analysis: dict) -> list: var p = worldToScreen(n.x, n.y); var rad = nodeRadius(n) * Math.max(0.6, Math.min(1.4, view.scale)); var dim = highlightSet && !highlightSet[n.id]; + var pulsing = n.kind === 'entry' && pulseUntil !== null && t < pulseUntil && !dim; + if (pulsing){ + // Radar-ping ring: expands outward and fades, repeating on a ~1.3s cycle + // until pulseUntil, so the entry node reads as "start here" on first view. + // Stroked in colors.text (not n.color) because it's the one color guaranteed + // to contrast against the canvas background in both themes -- a translucent + // green ring reads fine on a near-black canvas but washes out on a near-white one. + var fadeOut = Math.min(1, (pulseUntil - t) / 900); + var cyclePos = (t % 1300) / 1300; + var ringR = rad * (1.6 + cyclePos * 2.2); + ctx.beginPath(); + ctx.arc(p[0], p[1], ringR, 0, 7); + ctx.strokeStyle = colors.text; + ctx.lineWidth = 2.5; + ctx.globalAlpha = (0.85 - cyclePos * 0.55) * fadeOut; + ctx.stroke(); + ctx.globalAlpha = 1; + } ctx.globalAlpha = dim ? 0.15 : 1; ctx.beginPath(); if (n.kind === 'service' || n.kind === 'entry'){ var s2 = rad * 1.5; + if (pulsing){ + ctx.shadowColor = colors.text; + ctx.shadowBlur = 12 + 6 * Math.sin(t / 180); + } ctx.fillStyle = nodeFill(n); ctx.strokeStyle = colors.bg; ctx.lineWidth = 2; ctx.roundRect ? ctx.roundRect(p[0]-s2/2, p[1]-s2/2, s2, s2, 4) : ctx.rect(p[0]-s2/2, p[1]-s2/2, s2, s2); ctx.fill(); ctx.stroke(); + if (pulsing) ctx.shadowBlur = 0; } else { ctx.arc(p[0], p[1], rad, 0, 7); ctx.fillStyle = nodeFill(n); @@ -1584,18 +1632,191 @@ def infer_flows(analysis: dict) -> list: } var running = false; + var pulseUntil = null; + var graphVisible = false; + function armPulseIfReady(){ + // Only ever arms once, the first time the graph is actually visible AND + // interactive -- if the guided tour is still running, its overlay blocks + // clicks on the entry node, so arming here would burn the pulse while the + // user literally can't act on it. Re-tried on graph visibility changes and + // again the moment the tour ends (see wfgen-tour-end listener below). + if (pulseUntil === null && !reduceMotion && graphVisible && !window.__wfgenTourActive){ + pulseUntil = performance.now() + 10000; + } + } var obs = new IntersectionObserver(function(entries){ entries.forEach(function(en){ - if (en.isIntersecting && !running){ running = true; requestAnimationFrame(draw); } - else if (!en.isIntersecting){ running = false; } + graphVisible = en.isIntersecting; + if (en.isIntersecting){ + if (!running){ running = true; requestAnimationFrame(draw); } + armPulseIfReady(); + } else { + running = false; + } }); }, {threshold: 0.01}); obs.observe(wrap); + document.addEventListener('wfgen-tour-end', armPulseIfReady); running = true; requestAnimationFrame(draw); })(); """ +TOUR_JS = """ +(function(){ + var STORAGE_KEY = 'wfgen_tour_v1_dismissed'; + var steps = [ + {sel: '.stat-row', title: 'Capacity stats', body: "These are modeled estimates, not measurements. Expand \\u201cHow was Modeled Throughput computed?\\u201d below for the exact math, or run the generated k6 script against a real deploy to check them."}, + {sel: '#tour-derivation', title: 'Where the numbers come from', body: 'Every number above traces back to something concrete \\u2014 worker count \\u00d7 per-worker concurrency, ranked against whichever constraint is tightest. Click either panel for the full derivation or a ready-to-run load test.'}, + {sel: '#section-graph', title: 'Codebase graph', body: 'Every node is a real file; every edge is an actual import or a pattern-confirmed service call \\u2014 not a guess. Drag to pan, scroll to zoom, click a node to isolate its connections, or search by name.'}, + {sel: '#section-architecture', title: 'Architecture diagram', body: 'Solid boxes are confirmed in your source code; dashed boxes are declared in a manifest but never matched to real usage.'}, + {sel: '#section-flows', title: 'Data flow paths', body: 'Each card animates a real request path \\u2014 write, read, background jobs \\u2014 inferred from what was actually detected.'}, + {sel: '#section-concurrency', title: 'Concurrency model', body: "Layer-by-layer: what model each component uses, its ceiling, and what's actually limiting it."}, + {sel: '#section-bottlenecks', title: 'Bottleneck analysis', body: 'Ranked from what saturates first (CRITICAL) to least pressing (LOW), each with a concrete mitigation.'}, + {sel: '#theme-toggle', title: 'One more thing', body: 'Toggle light/dark mode anytime. That\\u2019s the tour \\u2014 click the ? button to see it again.'} + ]; + + var active = []; + var cur = -1; + var overlay, spot, card; + + function storageGet(){ + try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; } + } + function storageSet(){ + try { localStorage.setItem(STORAGE_KEY, '1'); } catch (e) {} + } + + function resolveSteps(){ + return steps + .map(function(s){ return {def: s, el: document.querySelector(s.sel)}; }) + .filter(function(s){ + if (!s.el) return false; + var r = s.el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }); + } + + function buildUI(){ + overlay = document.createElement('div'); + overlay.className = 'tour-overlay'; + spot = document.createElement('div'); + spot.className = 'tour-spot'; + card = document.createElement('div'); + card.className = 'tour-card'; + card.innerHTML = + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
'; + document.body.appendChild(overlay); + document.body.appendChild(spot); + document.body.appendChild(card); + + card.querySelector('.tour-close').addEventListener('click', endTour); + card.querySelector('.tour-skip').addEventListener('click', endTour); + card.querySelector('.tour-back').addEventListener('click', function(){ go(cur - 1); }); + card.querySelector('.tour-next').addEventListener('click', function(){ + if (cur >= active.length - 1) { endTour(); } else { go(cur + 1); } + }); + document.addEventListener('keydown', function(e){ + if (!overlay || overlay.style.display === 'none') return; + if (e.key === 'Escape') endTour(); + }); + window.addEventListener('resize', function(){ + if (cur >= 0 && cur < active.length) position(active[cur].el); + }); + } + + function position(el){ + var r = el.getBoundingClientRect(); + var pad = 8; + var top = r.top + window.scrollY - pad; + var left = r.left + window.scrollX - pad; + var width = r.width + pad * 2; + var height = r.height + pad * 2; + spot.style.top = top + 'px'; + spot.style.left = left + 'px'; + spot.style.width = width + 'px'; + spot.style.height = height + 'px'; + + var cardWidth = 320; + var viewportH = window.innerHeight; + var margin = 16; + var oversized = r.height > viewportH - 2 * margin; + var cardTop, cardLeft; + if (oversized) { + cardTop = window.scrollY + viewportH - margin; + card.style.transform = 'translateY(-100%)'; + } else { + var spaceBelow = viewportH - r.bottom; + var placeBelow = spaceBelow > 200 || spaceBelow > r.top; + if (placeBelow) { + cardTop = top + height + 12; + card.style.transform = 'none'; + } else { + cardTop = top - 12; + card.style.transform = 'translateY(-100%)'; + } + } + cardLeft = Math.min(Math.max(left, margin), window.scrollX + document.documentElement.clientWidth - cardWidth - margin); + card.style.top = cardTop + 'px'; + card.style.left = cardLeft + 'px'; + } + + function go(i){ + if (i < 0 || i >= active.length) return; + cur = i; + var step = active[i]; + step.el.scrollIntoView({block: 'center', behavior: 'smooth'}); + card.querySelector('.tour-step-count').textContent = (i + 1) + ' / ' + active.length; + card.querySelector('.tour-title').textContent = step.def.title; + card.querySelector('.tour-body').textContent = step.def.body; + card.querySelector('.tour-back').disabled = (i === 0); + card.querySelector('.tour-next').textContent = (i === active.length - 1) ? 'Done' : 'Next'; + setTimeout(function(){ position(step.el); }, 350); + } + + function startTour(){ + active = resolveSteps(); + if (!active.length) return; + if (!overlay) buildUI(); + overlay.style.display = 'block'; + spot.style.display = 'block'; + card.style.display = 'block'; + window.__wfgenTourActive = true; + go(0); + } + + function endTour(){ + storageSet(); + if (overlay) overlay.style.display = 'none'; + if (spot) spot.style.display = 'none'; + if (card) card.style.display = 'none'; + cur = -1; + window.__wfgenTourActive = false; + document.dispatchEvent(new Event('wfgen-tour-end')); + } + + var restartBtn = document.getElementById('tour-restart'); + if (restartBtn) restartBtn.addEventListener('click', startTour); + + if (!storageGet()) { + // Reserve this now, not inside the delayed startTour() call: the graph's + // IntersectionObserver fires its first check within a frame or two of + // load, well before this 700ms delay elapses, and if the graph section + // happens to already be in the initial viewport it would otherwise arm + // the entry-node pulse before the tour visibly starts. + window.__wfgenTourActive = true; + setTimeout(startTour, 700); + } +})(); +""" + def _box(name: str, desc: str, color: str, code: str = '', extra_desc: str = '', declared: bool = False, icon: str = '') -> str: bg = C.get(f'{color}_d', '#1a2535') @@ -2053,7 +2274,7 @@ def stat(val: str, color: str, label: str, sub: str = '') -> str: " This is static analysis, not live monitoring — particle motion shows dependency direction (importer → imported), not request traffic. Pass --access-log <path> to overlay real per-route request counts instead." ) graph_section = f""" -
+
Codebase Graph — {graph_note} · {gm['links']} real connections

Every node is a real file{' or directory' if gm['aggregated'] else ''} in this repo; every line is an actual import statement, or a pattern-confirmed service call scanned per-file (the same signal behind the summary below, localized to its source).{traffic_note} Drag a node, scroll to zoom, click to isolate its connections, or search below.

@@ -2094,15 +2315,16 @@ def stat(val: str, color: str, label: str, sub: str = '') -> str: +

{project_name} — System Workflow

Component communication map · concurrent request capacity · bottleneck analysis · {today}

{stats}

Capacity figures are static-analysis estimates (heuristic: ~100 concurrent tasks per async worker), not load-test results.

-{derivation_html} +
{derivation_html}
{graph_section} -
+
Full System Architecture — {component_count} Components

Detection is pattern-based static analysis. Dashed boxes are dependencies declared in your manifest but not matched to a usage pattern in source — may be wired through a custom abstraction. Solid boxes are pattern-confirmed in source. A component not shown here isn't confirmed absent from the project — only undetected.

{arch_html}
@@ -2110,17 +2332,17 @@ def stat(val: str, color: str, label: str, sub: str = '') -> str: {route_html}
-
+
Data Flow Paths — Step by Step
{flow_html}
-
+
Concurrency Model — Layer by Layer
{table_html}
-
+
Bottleneck Analysis — Where the System Saturates First
{bn_html}
@@ -2129,6 +2351,7 @@ def stat(val: str, color: str, label: str, sub: str = '') -> str:
+ """