Compare commits

...

54 Commits

Author SHA1 Message Date
Deep Mehta
c29fbe78ba Merge branch 'main' into feat/onboarding-tour-pr7 2026-07-15 16:01:43 -07:00
Maanil Verma
1e0d98a22c Merge branch 'main' into feat/onboarding-tour-pr7 2026-07-16 00:17:10 +05:30
Maanil Verma
24ff3acc93 fix(onboarding-tour): border the Getting Started tabs with a theme token
Replace a raw border-white/10, which is unique in the repo and not theme-aware.

Drop the header comment claiming the toolbar stays visible: the takeover is
opaque at z-2000 and the actionbar sits below it.
2026-07-16 00:15:58 +05:30
Maanil Verma
3c11078718 refactor(onboarding-tour): narrow the adapter and store surface
Unexport CoachMarkPosition and the store's reset, neither of which has a
consumer outside its module, and take clamp from es-toolkit.
2026-07-16 00:15:36 +05:30
Maanil Verma
f5d407e8ef feat(onboarding-tour): finish the tour with a leading tick
Name the last step's action Finish and lead the button with the tick, matching
Back's leading arrow.

Cover the per-shape Upload and Prompt copy: both fixtures omitted a shape, so
every case fell through to `other` and the variants users see never rendered.
2026-07-16 00:15:11 +05:30
Maanil Verma
73021dcfab test(onboarding-tour): drop assertions that cannot fail
The settingSet guard watched a store the controller never imports, and the
duplicate execution-event case hit the same branch as the one above it.
2026-07-16 00:12:27 +05:30
Maanil Verma
7c0d6046e3 fix(onboarding-tour): tag step_viewed and skipped with the template
Both events declare template_id but omitted it, leaving the funnel events that
carry the drop-off signal unattributable to a template.
2026-07-16 00:12:01 +05:30
Maanil Verma
90bb28eb99 test(onboarding-tour): reach the gate the entry tests name
Each case left isCloud false, so isOnboardingCandidate returned on its first
guard and the flag, new-user, and subscription branches never ran — deleting
any of them kept the suite green.
2026-07-16 00:11:37 +05:30
Maanil Verma
40e10a3bb1 refactor(onboarding-tour): split the overlay's motion and rect tracking into composables
Choreography owns the step's timing — glide, frame, settle, copy — and the rect
tracker owns the per-frame geometry, leaving the overlay to render.

Publish rects only when the geometry changes: replacing the reactive rect every
frame re-rendered the overlay at 60fps on a still canvas.
2026-07-16 00:11:07 +05:30
Maanil Verma
bb1b8473d7 refactor(onboarding-tour): prune comments that restate the code
Comments that narrate what the next line does, or recount how the code
came to be, go stale and add nothing a reader cannot see. Cut them across
the tour folder, keep the ones carrying a real constraint, and prefer
JSDoc so the docs sit where they are read.

Reattach canvasTransformKey's JSDoc, orphaned above SETTLE_FRAMES when
trackSettle was extracted.

Convert the adapter's parameterized tests to it.for, satisfying
vitest/consistent-each-for.
2026-07-15 22:58:14 +05:30
Maanil Verma
c9fcd52541 feat(onboarding-tour): dock the nudge and fade it in after the result lands 2026-07-15 22:09:54 +05:30
Maanil Verma
68b40e123d feat(onboarding-tour): frame steps against the canvas region and sequence the motion
Size the framing from the node's bounds and the room the coach-mark needs, so a
tall node backs off instead of overflowing. Place the mark against the canvas
rect, not the window, so it never lands under the top bar.

Glide the mark on step change, pin it while the canvas moves, and hold the copy
until the transform settles — only one thing moves at a time.
2026-07-15 22:09:54 +05:30
Maanil Verma
af4eda246b fix(onboarding-tour): report the run outcome once the step advances to Result
The Run click advances the step before execution reports, so gating the outcome
on the Run step dropped every event whenever a Result step followed — the media
never resolved and "Generating…" ran forever.
2026-07-15 22:09:54 +05:30
Maanil Verma
9153650e74 feat(onboarding-tour): key the Upload and Prompt copy to the workflow shape 2026-07-15 22:09:54 +05:30
Maanil Verma
6d8f6ebe6f Merge branch 'main' into feat/onboarding-tour-pr7 2026-07-15 17:18:53 +05:30
Maanil Verma
3173134623 fix(onboarding-tour): resolve the eligibility gate's dependencies during setup
The gate runs after an await, so a first-time call to useSubscription reached
Firebase auth with no injection context and threw. Callers resolve the
composables in their setup scope and pass them in.
2026-07-15 16:45:46 +05:30
Maanil Verma
0c908c24d0 feat(onboarding-tour): fill the Tutorials tab and drop the Import tab
Replace the "coming soon" placeholders with four cards linking to the docs, and
remove the Import tab until the flow behind it ships.

- Extract the card shell so templates, tutorials, and the loading skeletons
  share one component
- Show skeleton cards while the templates store loads, instead of an empty grid
- Type tutorial thumbnails against the curated ids the screen loads, so a card
  cannot reference a template that never resolves
2026-07-15 16:17:53 +05:30
Maanil Verma
312d47e24d perf(onboarding-tour): prefetch templates when Getting Started is shown
The screen only loaded them once mounted, leaving its card grid empty for the
length of the fetch. Start the load at the point the entry flag is set.
2026-07-15 16:16:59 +05:30
Maanil Verma
18f2f90d9e refactor(onboarding-tour): share one eligibility predicate between screen and tour
The Getting Started screen and the tour derived the same decision separately, and
had already drifted: only the tour checked isCloud, so a desktop session could
show the screen and then have the tour decline, stranding the user on a bare
canvas. Both now read isOnboardingCandidate.
2026-07-15 16:16:15 +05:30
Maanil Verma
eadbf636a2 fix(onboarding-tour): address review findings
- scope the run-outcome listener to the Run step so stray execution
  events on earlier steps can't fire lifecycle telemetry out of order
- share isUpgradeModalOpen from the store instead of duplicating the
  UPGRADE_DIALOG_KEYS check in the nudge and controller
- catch loadWorkflowTemplates rejections on the Getting Started screen
- make CuratedTemplateId the canonical union in roleResolver and reuse
  it across the fixtures and the Getting Started screen
- cover the execution_interrupted run outcome
- clean up manually appended DOM in overlay tests via try/finally
2026-07-15 00:52:20 +05:30
Maanil Verma
f6398b99ca Merge branch 'feat/onboarding-tour-pr7' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/onboarding-tour-pr7 2026-07-15 00:30:13 +05:30
Maanil Verma
2ecf41b19f fix(onboarding-tour): end the tour when the upgrade paywall opens
A no-subscription run is blocked before it queues, so no execution event fires
and the tour hung on the Result step. Watch the upgrade dialog and end the tour
when it opens, and make end idempotent so the gate and watcher can't double-fire.
2026-07-15 00:28:31 +05:30
Maanil Verma
64326a21f7 Merge branch 'main' into feat/onboarding-tour-pr7 2026-07-15 00:05:00 +05:30
Maanil Verma
c59a6f6c72 Merge branch 'feat/onboarding-tour-pr7' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/onboarding-tour-pr7 2026-07-15 00:04:05 +05:30
Maanil Verma
66ad7f5b09 fix(onboarding-tour): restore the tour start gate
Drop the local-testing bypass so the tour honors the cloud, subscription,
new-user, and feature-flag checks again.
2026-07-15 00:02:30 +05:30
Maanil Verma
577af01081 Merge branch 'main' into feat/onboarding-tour-pr7 2026-07-14 23:49:01 +05:30
Maanil Verma
9869d39a63 fix(onboarding-tour): catch the URL-launched tour start
Attach a catch to the fire-and-forget beginTour calls on the ?template=/?share=
path so a start failure can't surface as an unhandled rejection.
2026-07-14 23:08:08 +05:30
Maanil Verma
e6f16b4ec0 fix(onboarding-tour): gate Getting Started on the same checks as the tour
Require subscription mode alongside the new-user and flag checks so the takeover
never shows to a user the tour will decline, which would dismiss into a bare
canvas.
2026-07-14 23:07:38 +05:30
Maanil Verma
bf805b0e34 fix(onboarding-tour): always show the nudge, deferring only around the paywall
Move the "defer while the upgrade modal is open, else show" rule into showNudge
so the post-tour nudge surfaces on every exit and never overlaps the upgrade
modal. Share the upgrade dialog keys from the subscription dialog.
2026-07-14 23:07:26 +05:30
Maanil Verma
8332ff7b92 fix(onboarding-tour): drive run outcome from execution events
Report the run and surface the nudge on the execution_success, execution_error,
and execution_interrupted events instead of the ambiguous activeJobId reset, so
failed and interrupted runs carry their real status and don't count as success.
Complete the tour on a successful run when Run is the last step.
2026-07-14 23:07:18 +05:30
Maanil Verma
e17a6153e8 feat(onboarding-tour): resolve tour roles through a shared registry-backed helper
Inject the node registry into role resolution from one place so the readiness
gate and the tour see the same roles and registry-only custom sinks resolve.
2026-07-14 23:07:10 +05:30
Maanil Verma
103d6cdf23 fix(onboarding-tour): spotlight the subgraph host, not a pinned inner node
Resolve the override prompt's spotlight target to a root-graph node and drop the
prompt when the pinned inner id is absent, so the coach-mark never points at a
node inside a collapsed subgraph.
2026-07-14 23:03:31 +05:30
Maanil Verma
ea0b00e162 feat(onboarding-tour): redesign the tour, nudge, and template surfaces
Give the nudge a media header leading with the generated result, rebuild the
coach-mark and template cards, and show a generating indicator in place of the
pending result copy. Widen the coach-mark gap, hug node boxes without padding,
and drop the unused prompt-port hint.
2026-07-14 21:02:12 +05:30
Maanil Verma
352576cf42 fix(onboarding-tour): keep the result media alive after the tour ends
Clear the captured media with the nudge lifecycle instead of on tour reset, so
the nudge keeps showing the user's result once onboarding closes. Surface the
nudge on every tour end and drop the now-unused prompt-port fallback state.
2026-07-14 21:01:20 +05:30
Maanil Verma
7fa292b993 feat(onboarding-tour): show the loading state on the getting started grid
Spin the selected template card through the load and readiness gate, and refresh
the tour copy with pending and ready result states for image and video.
2026-07-14 18:13:06 +05:30
Maanil Verma
45ac184843 feat(onboarding-tour): capture the result and drive the nudge from the store
Wait for the sink output URL after a run to record the result media by kind,
and hold the explore-templates nudge across the tour end so it can surface once
the run finishes or the upgrade modal closes.
2026-07-14 18:12:18 +05:30
Maanil Verma
39eca5ee06 feat(onboarding-tour): gate tour start on canvas readiness
Wait for the loaded template to reach the live canvas before starting, advance
the run step on the Run button click, and report the run and nudge when the
job completes. Drop the unused subgraph focus path down to restoreView.
2026-07-14 18:11:50 +05:30
Maanil Verma
32ce41309c feat(onboarding-tour): place the coach-mark beside its target
Compute a non-overlapping coach-mark position around the spotlit node and
split the reveal mask from the spotlight rings so only the current step is
highlighted while prior reveals stay dim.
2026-07-14 18:09:26 +05:30
Maanil Verma
b3b245323d feat(onboarding-tour): resolve custom sinks via the node registry
Fall back to a registry lookup for terminal output nodes so share and
?template= workflows with custom save nodes still resolve a sink and media
kind, guarding the attacker-craftable node type with Object.hasOwn.
2026-07-14 18:08:46 +05:30
Maanil Verma
4460f67534 feat(onboarding-tour): report the run on real execution success
Spotlight the inner prompt node once focus enters the subgraph and restore the
top-level view on other steps. Report the run and capture result media when an
execution actually succeeds rather than on arrival at the Run step, and keep
auto-advance from skipping the Run and Result steps.
2026-07-13 19:34:07 +05:30
Maanil Verma
8a5013d082 feat(onboarding-tour): redesign getting started with a tabbed template grid
Replace the toggle group with the Tab component, drop the start-from-scratch
and discover-all controls, and load the templates store when the screen opens.
2026-07-13 19:33:37 +05:30
Maanil Verma
d69434182c refactor(tab): forward a class prop to TabList 2026-07-13 19:32:51 +05:30
Maanil Verma
8f6cbfda51 feat(onboarding-tour): add post-run explore-templates nudge
Surface a bottom-right nudge after the first successful run, and as the
fallback once the no-funds upgrade modal closes. Both flip a persistent
store flag that outlives the tour; it fires at most once and permanent
dismiss keeps it from resurfacing.
2026-07-13 15:38:27 +05:30
Maanil Verma
4990861a33 fix(onboarding-tour): stub GettingStartedScreen in the GraphView test
It mounts unstubbed and calls useI18n() without an i18n plugin, crashing
the GraphView suite; stub it like the other child components.
2026-07-13 15:37:47 +05:30
Maanil Verma
27eef95fad feat(onboarding-tour): gate the run step on available funds
At the Run step, users with no runs get the upgrade modal and the tour ends into
the post-run nudge; the funds check is UX only, never the signup method.
2026-07-13 14:46:30 +05:30
Maanil Verma
6c16b58d2d feat(onboarding-tour): render the generated result inline
Capture the sink's output on execution_success and show it in the coach-mark
as an <img> or <video> chosen from the resolved media kind, not the output MIME.
2026-07-13 14:45:58 +05:30
Maanil Verma
3ab6c449d2 feat(onboarding-tour): start the tour from share and template URLs
Run the tour directly on a workflow loaded via ?share= / ?template=, skipping
Getting Started, and thread the launch entry into the started telemetry. Gate
once-only on isNewUser() so the tour still fires the session loadDefaultWorkflow
sets Comfy.TutorialCompleted. Surface the loaded template id from the URL loader
only after it validates and loads, so no unvalidated id reaches the resolver.
2026-07-13 13:55:48 +05:30
Maanil Verma
1375b2a383 feat(onboarding-tour): add Getting Started screen and normal-signup entry
Show a fresh user the Getting Started takeover instead of the template
browser when the flag is on; picking a curated template loads it and
starts the tour. Route share/template URL arrivals past this branch.
2026-07-13 12:40:48 +05:30
Maanil Verma
c60d776ce1 feat(onboarding-tour): wire step machine to run the tour
Add subgraph-aware prompt focus with port fallback, store start/advance/back/end
actions with accumulating node reveal, controller orchestration (gating,
serialize, grace timer, telemetry), and overlay copy/navigation binding.
2026-07-13 11:38:20 +05:30
Maanil Verma
534547c8ed feat(onboarding-tour): add template-agnostic role resolver and sequence builder 2026-07-13 10:08:24 +05:30
Maanil Verma
e2d9040f8d feat(onboarding-tour): add progressive-disclosure overlay
Teleported SVG-mask overlay that dims the canvas and spotlights only the
store's revealed nodes (true-hide, no graph mutation), with a coach-mark
bubble shell (counter + Skip + content/actions slots) styled from Figma
tokens. Presentation only: Skip emits, step content arrives via slots.
Mounted in GraphView; renders only while the tour is active.
2026-07-12 21:48:49 +05:30
Maanil Verma
05d9fef389 feat(onboarding-tour): add canvas spotlight adapter
Pure litegraph reader mapping node/subgraph bounds and port positions to
client rects for the tour overlay: nodeClientRect, portClientPos,
maskRectsFor, and fitToBounds. Sole module touching app.canvas; view-only,
no graph mutation.
2026-07-12 21:48:20 +05:30
Maanil Verma
5af5077550 feat(onboarding-tour): scaffold store, gating controller, and role types
Add the onboardingTourStore (phase, step index, resolved roles, revealed
node ids, result media, run status) with reset(), a shouldStartTour()
gating truth table (cloud + subscription mode + new user + flag +
not-completed), and shared role/media types. Feature-flagged off; no
user-visible change.
2026-07-12 17:53:35 +05:30
Maanil Verma
2585a9fb56 feat(telemetry): add onboarding tour flag, events, and remote config
Add the onboarding_tour_enabled feature flag and app:onboarding_tour_*
events (started, step_viewed, run_triggered, completed, skipped,
upgrade_shown, nudge_shown, explore_templates_clicked) with typed
metadata and PostHog/registry dispatch. shape is a role-derived label
(t2i/i2v/image-edit/other), never a template id; no PII in props.
2026-07-12 17:52:56 +05:30
52 changed files with 20242 additions and 20 deletions

View File

@@ -11,6 +11,7 @@
<Panel
ref="panelRef"
data-testid="comfy-actionbar"
class="pointer-events-auto"
:style="style"
:class="panelClass"

View File

@@ -180,6 +180,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import { useOnboardingTourController } from '@/renderer/extensions/onboardingTour/useOnboardingTourController'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { UnauthorizedError } from '@/scripts/api'
@@ -504,6 +505,9 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
let templateFromUrl: Awaited<
ReturnType<typeof workflowPersistence.loadTemplateFromUrlIfPresent>
>
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
@@ -561,11 +565,38 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.loadTemplateFromUrlIfPresent()
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
} finally {
workspaceStore.spinner = false
}
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
const sharedFromUrl =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// A ?template=/?share= URL loads a workflow directly (Getting Started is
// skipped). Start the onboarding tour on it; beginTour() self-gates and reads
// the now-loaded graph, so the loaders above must have finished first.
const startTourFromUrl = (
options?: Parameters<
ReturnType<typeof useOnboardingTourController>['beginTour']
>[0]
) =>
useOnboardingTourController()
.beginTour(options)
.catch((error: unknown) => {
console.error('[onboardingTour] failed to start from URL', error)
})
if (templateFromUrl.loaded) {
void startTourFromUrl({
templateId: templateFromUrl.templateId,
entry: 'template_url'
})
} else if (
sharedFromUrl === 'loaded' ||
sharedFromUrl === 'loaded-without-assets'
) {
void startTourFromUrl({ entry: 'share_url' })
}
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,

View File

@@ -1,14 +1,21 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2">
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<T>({ required: true })
function select(value: string) {

View File

@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
SIGNUP_TURNSTILE = 'signup_turnstile',
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
}
/**
@@ -208,6 +209,13 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get onboardingTourEnabled() {
return resolveFlag(
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
remoteConfig.value.onboarding_tour_enabled,
false
)
}
})

View File

@@ -2317,6 +2317,90 @@
"member": "Member"
}
},
"onboardingTour": {
"overlayLabel": "Getting started tour",
"preparing": "Building your template…",
"skip": "Skip",
"back": "Back",
"next": "Next",
"complete": "Finish",
"stepCounter": "{current} of {total}",
"step": {
"upload": {
"t2i": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
},
"i2v": {
"title": "Start with an image",
"body": "This picture is your first frame. Swap in your own whenever you like."
},
"image-edit": {
"title": "Start with an image",
"body": "This is the picture you'll edit. Swap in your own whenever you like."
},
"other": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
}
},
"prompt": {
"t2i": {
"title": "Describe what you want",
"body": "Your image gets built from this description. Try anything you like."
},
"i2v": {
"title": "Describe how it should move",
"body": "The image sets the scene. This tells it what happens next."
},
"image-edit": {
"title": "Say what to change",
"body": "Your image stays as it is. Describe what you want different."
},
"other": {
"title": "Tell it what to make",
"body": "This is your prompt. Change it to change your result."
}
},
"run": {
"title": "Run your workflow",
"body": "Press Run to start generating your result"
},
"result": {
"image": {
"title": "And there it is",
"body": "Your new image lands right here — ready to download or share."
},
"video": {
"title": "And there it is",
"body": "Your new video lands right here — ready to download or share."
}
}
},
"generating": "Generating…",
"gettingStarted": {
"title": "Get started with graph",
"subtitle": "Start a workflow from scratch or load one that's ready to go.",
"screenLabel": "Get started with graph",
"tabsLabel": "Getting started options",
"tabs": {
"templates": "Templates",
"tutorials": "Tutorials"
},
"tutorials": {
"interfaceOverview": "Interface overview",
"textToImage": "Text to image",
"imageToImage": "Image to image",
"inpaint": "Inpainting"
}
},
"nudge": {
"title": "That was one of hundreds",
"body": "You just made your first. Explore what else you can build.",
"dismiss": "Not now",
"explore": "Explore templates"
}
},
"auth": {
"apiKey": {
"title": "API Key",

View File

@@ -13,6 +13,9 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
/** Dialog keys the subscription/upgrade flow opens; callers gate around these. */
export const UPGRADE_DIALOG_KEYS = [FREE_TIER_DIALOG_KEY, DIALOG_KEY] as const
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**

View File

@@ -116,6 +116,7 @@ export type RemoteConfig = {
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
onboarding_tour_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -17,6 +17,12 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourCompletedMetadata,
OnboardingTourRunTriggeredMetadata,
OnboardingTourSkippedMetadata,
OnboardingTourStartedMetadata,
OnboardingTourStepViewedMetadata,
OnboardingTourUpgradeShownMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -291,4 +297,54 @@ export class TelemetryRegistry implements TelemetryDispatcher {
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
}
trackOnboardingTourStarted(metadata: OnboardingTourStartedMetadata): void {
this.dispatch((provider) => provider.trackOnboardingTourStarted?.(metadata))
}
trackOnboardingTourStepViewed(
metadata: OnboardingTourStepViewedMetadata
): void {
this.dispatch((provider) =>
provider.trackOnboardingTourStepViewed?.(metadata)
)
}
trackOnboardingTourRunTriggered(
metadata: OnboardingTourRunTriggeredMetadata
): void {
this.dispatch((provider) =>
provider.trackOnboardingTourRunTriggered?.(metadata)
)
}
trackOnboardingTourCompleted(
metadata: OnboardingTourCompletedMetadata
): void {
this.dispatch((provider) =>
provider.trackOnboardingTourCompleted?.(metadata)
)
}
trackOnboardingTourSkipped(metadata: OnboardingTourSkippedMetadata): void {
this.dispatch((provider) => provider.trackOnboardingTourSkipped?.(metadata))
}
trackOnboardingTourUpgradeShown(
metadata: OnboardingTourUpgradeShownMetadata
): void {
this.dispatch((provider) =>
provider.trackOnboardingTourUpgradeShown?.(metadata)
)
}
trackOnboardingTourNudgeShown(): void {
this.dispatch((provider) => provider.trackOnboardingTourNudgeShown?.())
}
trackOnboardingTourExploreTemplatesClicked(): void {
this.dispatch((provider) =>
provider.trackOnboardingTourExploreTemplatesClicked?.()
)
}
}

View File

@@ -24,6 +24,12 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourCompletedMetadata,
OnboardingTourRunTriggeredMetadata,
OnboardingTourSkippedMetadata,
OnboardingTourStartedMetadata,
OnboardingTourStepViewedMetadata,
OnboardingTourUpgradeShownMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -575,4 +581,44 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
...properties
})
}
trackOnboardingTourStarted(metadata: OnboardingTourStartedMetadata): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_STARTED, metadata)
}
trackOnboardingTourStepViewed(
metadata: OnboardingTourStepViewedMetadata
): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_STEP_VIEWED, metadata)
}
trackOnboardingTourRunTriggered(
metadata: OnboardingTourRunTriggeredMetadata
): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED, metadata)
}
trackOnboardingTourCompleted(
metadata: OnboardingTourCompletedMetadata
): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_COMPLETED, metadata)
}
trackOnboardingTourSkipped(metadata: OnboardingTourSkippedMetadata): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_SKIPPED, metadata)
}
trackOnboardingTourUpgradeShown(
metadata: OnboardingTourUpgradeShownMetadata
): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN, metadata)
}
trackOnboardingTourNudgeShown(): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN)
}
trackOnboardingTourExploreTemplatesClicked(): void {
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED)
}
}

View File

@@ -543,6 +543,56 @@ export interface WorkspaceInviteMetadata extends Record<string, unknown> {
count: number
}
/**
* Onboarding tour telemetry. `shape` labels the role-derived sequence, not the
* template — `'other'` is the honest bucket for graphs the resolver handles
* best-effort but that aren't a named shape. `step_key`/`template_id` carry no
* user content or share id, so no PII.
*/
export type OnboardingTourShape = 't2i' | 'i2v' | 'image-edit' | 'other'
export type OnboardingTourEntry =
| 'getting_started'
| 'share_url'
| 'template_url'
export type OnboardingTourStepKey = 'upload' | 'prompt' | 'run' | 'result'
export interface OnboardingTourStartedMetadata {
template_id?: string
shape: OnboardingTourShape
entry: OnboardingTourEntry
}
export interface OnboardingTourStepViewedMetadata {
template_id?: string
step_key: OnboardingTourStepKey
step_index: number
step_total: number
}
export type OnboardingTourRunStatus = 'success' | 'error' | 'interrupted'
export interface OnboardingTourRunTriggeredMetadata {
template_id?: string
shape: OnboardingTourShape
status: OnboardingTourRunStatus
}
export interface OnboardingTourCompletedMetadata {
template_id?: string
shape: OnboardingTourShape
}
export interface OnboardingTourSkippedMetadata {
template_id?: string
step_key: OnboardingTourStepKey
step_index: number
step_total: number
}
export interface OnboardingTourUpgradeShownMetadata {
template_id?: string
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -644,6 +694,22 @@ export interface TelemetryProvider {
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
// Onboarding tour events
trackOnboardingTourStarted?(metadata: OnboardingTourStartedMetadata): void
trackOnboardingTourStepViewed?(
metadata: OnboardingTourStepViewedMetadata
): void
trackOnboardingTourRunTriggered?(
metadata: OnboardingTourRunTriggeredMetadata
): void
trackOnboardingTourCompleted?(metadata: OnboardingTourCompletedMetadata): void
trackOnboardingTourSkipped?(metadata: OnboardingTourSkippedMetadata): void
trackOnboardingTourUpgradeShown?(
metadata: OnboardingTourUpgradeShownMetadata
): void
trackOnboardingTourNudgeShown?(): void
trackOnboardingTourExploreTemplatesClicked?(): void
}
/**
@@ -746,7 +812,18 @@ export const TelemetryEvents = {
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
// Page View
PAGE_VIEW: 'app:page_view'
PAGE_VIEW: 'app:page_view',
// Onboarding Tour
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
ONBOARDING_TOUR_STEP_VIEWED: 'app:onboarding_tour_step_viewed',
ONBOARDING_TOUR_RUN_TRIGGERED: 'app:onboarding_tour_run_triggered',
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
ONBOARDING_TOUR_UPGRADE_SHOWN: 'app:onboarding_tour_upgrade_shown',
ONBOARDING_TOUR_NUDGE_SHOWN: 'app:onboarding_tour_nudge_shown',
ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED:
'app:onboarding_tour_explore_templates_clicked'
} as const
export type TelemetryEventName =
@@ -803,3 +880,9 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| OnboardingTourStartedMetadata
| OnboardingTourStepViewedMetadata
| OnboardingTourRunTriggeredMetadata
| OnboardingTourCompletedMetadata
| OnboardingTourSkippedMetadata
| OnboardingTourUpgradeShownMetadata

View File

@@ -5,6 +5,7 @@ import { createApp, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useOnboardingEntryStore } from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
@@ -58,11 +59,17 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
})
}))
const templateLoaderMocks = vi.hoisted(() => ({
loadTemplateFromUrl: vi.fn(
async () => ({ loaded: false }) as { loaded: boolean; templateId?: string }
)
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
})
})
)
@@ -125,7 +132,48 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
get isCloud() {
return onboardingMocks.isCloud
}
}))
const onboardingMocks = vi.hoisted(() => ({
isCloud: false,
onboardingTourEnabled: false,
isNewUser: null as boolean | null,
isSubscriptionEnabled: true,
loadWorkflowTemplates: vi.fn(async () => {})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
loadWorkflowTemplates: onboardingMocks.loadWorkflowTemplates
})
})
)
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get onboardingTourEnabled() {
return onboardingMocks.onboardingTourEnabled
}
}
})
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => ({
isNewUser: () => onboardingMocks.isNewUser
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isSubscriptionEnabled: () => onboardingMocks.isSubscriptionEnabled
})
}))
vi.mock('../migration/migrateV1toV2', () => ({
@@ -206,6 +254,12 @@ describe('useWorkflowPersistenceV2', () => {
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = null
onboardingMocks.isSubscriptionEnabled = true
templateLoaderMocks.loadTemplateFromUrl.mockReset()
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({ loaded: false })
})
afterEach(() => {
@@ -617,5 +671,156 @@ describe('useWorkflowPersistenceV2', () => {
'Comfy.BrowseTemplates'
)
})
it('shows Getting Started instead of the templates browser for a flagged new user', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(entryStore.shouldShowGettingStarted).toBe(true)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser off the Cloud build, matching the tour gate', async () => {
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('prefetches templates when Getting Started is shown so its cards are ready', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(onboardingMocks.loadWorkflowTemplates).toHaveBeenCalled()
})
it('opens the templates browser when the flag is on but the user is not new', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser when the flag is off', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser, not Getting Started, when subscriptions are disabled', async () => {
// The tour refuses when subscriptions are off, so the takeover must not show
// — otherwise it would dismiss into a bare canvas with no tour.
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
onboardingMocks.isSubscriptionEnabled = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not show Getting Started for a flagged new user arriving via a template URL', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
routeMocks.query = { template: 'default-template-id' }
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
describe('loadTemplateFromUrlIfPresent', () => {
it('surfaces the validated template id the loader reports', async () => {
routeMocks.query = { template: 'image_z_image_turbo' }
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: true,
templateId: 'image_z_image_turbo'
})
})
it('reports not-loaded when the loader loads nothing', async () => {
routeMocks.query = {}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: false
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: false
})
})
it('hydrates preserved template intent before delegating to the loader', async () => {
preservedQueryMocks.payloads.template = {
template: 'image_z_image_turbo'
}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(templateLoaderMocks.loadTemplateFromUrl).toHaveBeenCalled()
})
})
})

View File

@@ -16,11 +16,13 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -28,9 +30,17 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { useNewUserService } from '@/services/useNewUserService'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import type { OnboardingCandidateDeps } from '../onboardingEntryStore'
import {
isOnboardingCandidate,
useOnboardingEntryStore
} from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
@@ -52,6 +62,13 @@ export function useWorkflowPersistenceV2() {
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
const templatesStore = useWorkflowTemplatesStore()
const entryStore = useOnboardingEntryStore()
const onboardingDeps: OnboardingCandidateDeps = {
subscription: useSubscription(),
newUserService: useNewUserService(),
featureFlags: useFeatureFlags()
}
const { onUserLogout } = useCurrentUser()
// Run migration on module load, passing clientId for tab state migration
@@ -179,7 +196,12 @@ export function useWorkflowPersistenceV2() {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
if (isOnboardingCandidate(onboardingDeps)) {
void templatesStore.loadWorkflowTemplates()
entryStore.showGettingStarted()
} else {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
}
} else {
await comfyApp.loadGraphData()
@@ -223,12 +245,10 @@ export function useWorkflowPersistenceV2() {
}
const loadTemplateFromUrlIfPresent = async () => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
}
// Hydrate any preserved ?template= intent into the route first; the loader
// reads the route and returns the id only after validating it.
await ensureTemplateQueryFromIntent()
return templateUrlLoader.loadTemplateFromUrl()
}
const loadSharedWorkflowFromUrlIfPresent = async () => {

View File

@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import type { useNewUserService } from '@/services/useNewUserService'
export interface OnboardingCandidateDeps {
subscription: ReturnType<typeof useSubscription>
newUserService: ReturnType<typeof useNewUserService>
featureFlags: ReturnType<typeof useFeatureFlags>
}
/**
* Shared by the Getting Started screen and the tour: the screen dismisses even
* when the tour then declines, so a looser gate on either side strands the user
* on a bare canvas. Deps are resolved by callers during setup — this runs after
* an await, where a first composable call would have no injection context.
*/
export function isOnboardingCandidate({
subscription,
newUserService,
featureFlags
}: OnboardingCandidateDeps): boolean {
if (!isCloud) return false
if (!subscription.isSubscriptionEnabled()) return false
if (newUserService.isNewUser() !== true) return false
if (!featureFlags.flags.onboardingTourEnabled) return false
return true
}
/**
* Seam between the persistence layer and the onboarding Getting Started screen.
*
* `loadDefaultWorkflow` decides when a fresh user should land on Getting
* Started, but the screen lives in `renderer/` and cannot be imported from this
* layer. Both sides share this flag: persistence turns it on; the
* renderer-mounted overlay shows itself while set and clears it on exit.
*/
export const useOnboardingEntryStore = defineStore('onboardingEntry', () => {
const shouldShowGettingStarted = ref(false)
function showGettingStarted() {
shouldShowGettingStarted.value = true
}
function dismissGettingStarted() {
shouldShowGettingStarted.value = false
}
return { shouldShowGettingStarted, showGettingStarted, dismissGettingStarted }
})

View File

@@ -97,6 +97,51 @@ describe('useTemplateUrlLoader', () => {
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('returns the loaded template id when the template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({
loaded: true,
templateId: 'flux_simple'
})
})
it('returns not-loaded when no template param is present', async () => {
mockQueryParams = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded without a template id when the template fails to load', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded when loading throws', async () => {
mockQueryParams = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded for an invalid template parameter', async () => {
mockQueryParams = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }

View File

@@ -64,18 +64,21 @@ export function useTemplateUrlLoader() {
* Loads template from URL query parameters if present
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async () => {
const loadTemplateFromUrl = async (): Promise<{
loaded: boolean
templateId?: string
}> => {
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
return
return { loaded: false }
}
if (!isValidParameter(templateParam)) {
console.warn(
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
)
return
return { loaded: false }
}
const sourceParam = (route.query.source as string | undefined) || 'default'
@@ -84,7 +87,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
)
return
return { loaded: false }
}
const modeParam = route.query.mode as string | undefined
@@ -96,7 +99,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
return { loaded: false }
}
if (modeParam && !isSupportedMode(modeParam)) {
@@ -121,11 +124,16 @@ export function useTemplateUrlLoader() {
templateName: templateParam
})
})
} else if (modeParam === 'linear') {
return { loaded: false }
}
if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
return { loaded: true, templateId: templateParam }
} catch (error) {
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
@@ -136,6 +144,7 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('g.errorLoadingTemplate')
})
return { loaded: false }
} finally {
cleanupUrlParams()
clearPreservedQuery(TEMPLATE_NAMESPACE)

View File

@@ -0,0 +1,91 @@
<template>
<div
v-if="skeleton"
:data-testid="testid"
class="relative aspect-square overflow-hidden rounded-2xl"
>
<Skeleton class="absolute inset-0 rounded-2xl" />
<Skeleton class="absolute inset-x-4 bottom-4 h-4 w-2/3 rounded-md" />
</div>
<div
v-else
role="button"
:tabindex="loading ? -1 : 0"
:data-testid="testid"
:aria-label="title"
:aria-busy="loading"
class="group/card focus-visible:ring-ring relative aspect-square cursor-pointer overflow-hidden rounded-2xl focus-visible:ring-1 focus-visible:outline-none"
@click="onSelect"
@keydown.enter.prevent="onSelect"
@keydown.space.prevent="onSelect"
>
<LazyImage
:src="imageSrc"
:alt="title"
image-class="size-full object-cover opacity-60 transition-all duration-300 ease-out group-hover/card:scale-105 group-hover/card:opacity-100"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-linear-to-b from-black/40 via-transparent via-50% to-black/40 transition-opacity duration-300 ease-out group-hover/card:opacity-60"
/>
<div
v-if="badgeIcon"
aria-hidden="true"
data-testid="getting-started-card-badge"
class="pointer-events-none absolute top-3 right-3 flex size-7 items-center justify-center rounded-full bg-black/50 text-base-foreground backdrop-blur-sm"
>
<i :class="cn(badgeIcon, 'size-4')" />
</div>
<h3
class="absolute inset-x-0 bottom-0 m-0 truncate p-4 text-sm font-semibold text-base-foreground drop-shadow-md"
:title
>
{{ title }}
</h3>
<div
v-if="loading"
aria-live="polite"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-base-background/70 backdrop-blur-md"
>
<DotSpinner :size="24" />
<span class="text-xs font-medium text-base-foreground/80">
{{ t('onboardingTour.preparing') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import DotSpinner from '@/components/common/DotSpinner.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
const {
imageSrc = '',
title = '',
loading = false,
skeleton = false,
badgeIcon = '',
testid
} = defineProps<{
imageSrc?: string
title?: string
loading?: boolean
skeleton?: boolean
badgeIcon?: string
testid?: string
}>()
const emit = defineEmits<{ select: [] }>()
const { t } = useI18n()
function onSelect() {
if (loading) return
emit('select')
}
</script>

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import GettingStartedScreen from './GettingStartedScreen.vue'
const meta: Meta<typeof GettingStartedScreen> = {
title: 'Renderer/OnboardingTour/GettingStartedScreen',
component: GettingStartedScreen,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
useOnboardingEntryStore().showGettingStarted()
// Template cards need the served template package; without a backend the
// curated lookups return nothing, so this catalogs the screen chrome
// (heading, tabs, placeholders). Card rendering is covered by the unit
// test.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { GettingStartedScreen },
template: '<GettingStartedScreen />'
})
export const Default: Story = { render }

View File

@@ -0,0 +1,274 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const mocks = vi.hoisted(() => ({
loadWorkflowTemplate: vi.fn(async () => true),
getTemplateThumbnailUrl: vi.fn(() => 'thumb.jpg'),
getTemplateTitle: vi.fn(
(template: TemplateInfo) => template.title ?? template.name
),
controllerBeginTour: vi.fn(async () => {}),
templatesByName: new Map<string, TemplateInfo>(),
templatesLoaded: true,
loadWorkflowTemplates: vi.fn(async () => {})
}))
function makeTemplate(name: string, title: string): TemplateInfo {
return {
name,
title,
mediaType: 'image',
mediaSubtype: 'jpg',
description: ''
}
}
const CARD_IDS = [
'image_krea2_turbo_t2i',
'image_z_image_turbo',
'video_ltx2_3_i2v',
'video_wan2_2_14B_i2v'
] as const
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
loadWorkflowTemplate: mocks.loadWorkflowTemplate,
getTemplateThumbnailUrl: mocks.getTemplateThumbnailUrl,
getTemplateTitle: mocks.getTemplateTitle
})
})
)
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
getTemplateByName: (name: string) => mocks.templatesByName.get(name),
get isLoaded() {
return mocks.templatesLoaded
},
loadWorkflowTemplates: mocks.loadWorkflowTemplates
})
})
)
vi.mock('./useOnboardingTourController', () => ({
useOnboardingTourController: () => ({ beginTour: mocks.controllerBeginTour })
}))
import GettingStartedScreen from './GettingStartedScreen.vue'
import { tutorialCards } from './tutorialCards'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import enMessages from '@/locales/en/main.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderScreen() {
return render(GettingStartedScreen, { global: { plugins: [i18n] } })
}
describe('GettingStartedScreen', () => {
let store: ReturnType<typeof useOnboardingEntryStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useOnboardingEntryStore()
store.showGettingStarted()
mocks.loadWorkflowTemplate.mockClear()
mocks.controllerBeginTour.mockClear()
mocks.loadWorkflowTemplates.mockClear()
mocks.templatesLoaded = true
mocks.templatesByName.clear()
CARD_IDS.forEach((id, i) =>
mocks.templatesByName.set(id, makeTemplate(id, `Template ${i + 1}`))
)
})
it('renders the four curated template cards on the Templates tab', () => {
renderScreen()
for (const id of CARD_IDS) {
expect(screen.getByTestId(`getting-started-card-${id}`)).toBeTruthy()
}
})
it('loads the templates store when opened unloaded so cards can resolve', () => {
mocks.templatesLoaded = false
renderScreen()
expect(mocks.loadWorkflowTemplates).toHaveBeenCalledOnce()
})
it('does not reload the templates store when it is already loaded', () => {
mocks.templatesLoaded = true
renderScreen()
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
})
it('does not load the templates store while the screen is hidden', () => {
store.dismissGettingStarted()
mocks.templatesLoaded = false
renderScreen()
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
})
it('loads the template then starts the tour when a card is picked', async () => {
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-image_z_image_turbo')
)
expect(mocks.loadWorkflowTemplate).toHaveBeenCalledWith(
'image_z_image_turbo',
'default'
)
expect(mocks.controllerBeginTour).toHaveBeenCalledWith({
templateId: 'image_z_image_turbo'
})
const loadOrder = mocks.loadWorkflowTemplate.mock.invocationCallOrder[0]
const startOrder = mocks.controllerBeginTour.mock.invocationCallOrder[0]
expect(loadOrder).toBeLessThan(startOrder)
expect(store.shouldShowGettingStarted).toBe(false)
})
it('marks the picked card busy while the template loads and the tour prepares', async () => {
let resolveLoad: (loaded: boolean) => void = () => {}
mocks.loadWorkflowTemplate.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveLoad = resolve
})
)
const user = userEvent.setup()
renderScreen()
const cardId = 'getting-started-card-image_z_image_turbo'
await user.click(screen.getByTestId(cardId))
await vi.waitFor(() =>
expect(screen.getByTestId(cardId)).toHaveAttribute('aria-busy', 'true')
)
resolveLoad(true)
})
it('keeps the screen up and does not start the tour when loading fails', async () => {
mocks.loadWorkflowTemplate.mockResolvedValueOnce(false)
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-video_ltx2_3_i2v')
)
expect(mocks.loadWorkflowTemplate).toHaveBeenCalled()
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
})
it('does not start the tour or dismiss when loading rejects', async () => {
mocks.loadWorkflowTemplate.mockRejectedValueOnce(new Error('load failed'))
const user = userEvent.setup()
renderScreen()
await user.click(
screen.getByTestId('getting-started-card-video_wan2_2_14B_i2v')
)
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
})
it('does not offer the Import workflow tab', () => {
renderScreen()
expect(screen.queryByRole('tab', { name: /import workflow/i })).toBeNull()
expect(screen.getAllByRole('tab')).toHaveLength(2)
})
it('shows skeleton cards instead of an empty grid while templates load', () => {
mocks.templatesLoaded = false
renderScreen()
expect(screen.getAllByTestId('getting-started-card-skeleton')).toHaveLength(
CARD_IDS.length
)
expect(
screen.queryByTestId('getting-started-card-image_z_image_turbo')
).toBeNull()
})
it('opens the tutorial docs in a new tab when a tutorial card is picked', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const user = userEvent.setup()
renderScreen()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
await user.click(
screen.getByTestId('getting-started-tutorial-text-to-image')
)
expect(openSpy).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/basic/text-to-image',
'_blank',
'noopener,noreferrer'
)
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
expect(store.shouldShowGettingStarted).toBe(true)
openSpy.mockRestore()
})
it('renders a card per tutorial on the Tutorials tab', async () => {
const user = userEvent.setup()
renderScreen()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
for (const tutorial of tutorialCards) {
expect(
screen.getByTestId(`getting-started-tutorial-${tutorial.id}`)
).toBeTruthy()
}
expect(
screen.queryByTestId('getting-started-card-image_z_image_turbo')
).toBeNull()
})
it('badges tutorial cards so they read apart from the template cards', async () => {
const user = userEvent.setup()
renderScreen()
expect(screen.queryByTestId('getting-started-card-badge')).toBeNull()
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
expect(screen.getAllByTestId('getting-started-card-badge')).toHaveLength(
tutorialCards.length
)
})
it('does not render when the entry flag is off', () => {
store.dismissGettingStarted()
renderScreen()
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View File

@@ -0,0 +1,203 @@
<template>
<Teleport v-if="visible" to="body">
<div
ref="screenRef"
class="fixed inset-0 z-2000 flex flex-col items-center justify-center bg-base-background px-8 focus:outline-none"
role="dialog"
:aria-label="t('onboardingTour.gettingStarted.screenLabel')"
tabindex="-1"
>
<div class="flex w-full max-w-5xl flex-col items-center gap-8">
<div class="flex flex-col items-center gap-3">
<h1
class="m-0 text-center text-[2.5rem]/11 font-medium text-base-foreground"
>
{{ t('onboardingTour.gettingStarted.title') }}
</h1>
<p class="m-0 text-center text-base/5 text-muted-foreground">
{{ t('onboardingTour.gettingStarted.subtitle') }}
</p>
</div>
<TabList
v-model="activeTab"
class="w-auto gap-1 rounded-full border border-border-subtle p-0.5"
:aria-label="t('onboardingTour.gettingStarted.tabsLabel')"
>
<Tab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
class="gap-2.5 rounded-full px-3 text-xs font-medium text-base-foreground hover:opacity-100 data-[state=active]:bg-tertiary-background data-[state=inactive]:opacity-70"
>
<i :class="cn(tab.icon, 'size-3.5')" aria-hidden="true" />
{{ t(tab.labelKey) }}
</Tab>
</TabList>
<div class="w-full">
<TabPanel
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:model-value="activeTab"
>
<div
v-if="tab.value === 'templates'"
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
>
<template v-if="templatesStore.isLoaded">
<GettingStartedTemplateCard
v-for="template in cards"
:key="template.name"
:template
:loading="loadingTemplateId === template.name"
class="min-w-0"
@select="onSelectTemplate"
/>
</template>
<template v-else>
<GettingStartedCard
v-for="id in CURATED_TEMPLATE_IDS"
:key="id"
skeleton
testid="getting-started-card-skeleton"
class="min-w-0"
/>
</template>
</div>
<div
v-else-if="tab.value === 'tutorials'"
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
>
<GettingStartedCard
v-for="tutorial in tutorialCards"
:key="tutorial.id"
:image-src="tutorialThumbnail(tutorial.thumbnailTemplate)"
:title="t(tutorial.titleKey)"
:badge-icon="TUTORIAL_BADGE_ICON"
:testid="`getting-started-tutorial-${tutorial.id}`"
class="min-w-0"
@select="openTutorial(tutorial.url)"
/>
</div>
</TabPanel>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import GettingStartedCard from './GettingStartedCard.vue'
import GettingStartedTemplateCard from './GettingStartedTemplateCard.vue'
import type { TutorialCard } from './tutorialCards'
import {
CURATED_TEMPLATE_IDS,
TUTORIAL_BADGE_ICON,
tutorialCards
} from './tutorialCards'
import { useOnboardingTourController } from './useOnboardingTourController'
type TabValue = 'templates' | 'tutorials'
const tabs = [
{
value: 'templates' as const,
labelKey: 'onboardingTour.gettingStarted.tabs.templates',
icon: 'icon-[lucide--layout-template]'
},
{
value: 'tutorials' as const,
labelKey: 'onboardingTour.gettingStarted.tabs.tutorials',
icon: 'icon-[lucide--tv-minimal-play]'
}
]
const { t } = useI18n()
const entryStore = useOnboardingEntryStore()
const { shouldShowGettingStarted: visible } = storeToRefs(entryStore)
const templatesStore = useWorkflowTemplatesStore()
const { loadWorkflowTemplate, getTemplateThumbnailUrl } = useTemplateWorkflows()
const controller = useOnboardingTourController()
const activeTab = ref<TabValue>('templates')
const loadingTemplateId = ref<string | null>(null)
const screenRef = useTemplateRef<HTMLElement>('screenRef')
const cards = computed(() =>
CURATED_TEMPLATE_IDS.map((id) => templatesStore.getTemplateByName(id)).filter(
(template) => template !== undefined
)
)
// Cards and per-card loads no-op until the templates store is loaded; nothing
// else loads it on this path, so load it (and focus the takeover) on open.
watch(
visible,
(isVisible) => {
if (!isVisible) return
if (!templatesStore.isLoaded) {
templatesStore.loadWorkflowTemplates().catch((error: unknown) => {
console.error('Failed to load onboarding templates:', error)
})
}
void nextTick(() => screenRef.value?.focus())
},
{ immediate: true }
)
function tutorialThumbnail(id: TutorialCard['thumbnailTemplate']) {
const template = templatesStore.getTemplateByName(id)
return template ? getTemplateThumbnailUrl(template, 'default') : ''
}
function openTutorial(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
async function onSelectTemplate(id: string) {
if (loadingTemplateId.value) return
// Keep the screen up (the card shows a spinner) through the load and the
// tour's readiness gate: a failed load leaves the user here to pick again,
// and the tour overlay takes over on top before the screen is dismissed, so
// the canvas never flashes bare.
loadingTemplateId.value = id
try {
let loaded = false
try {
loaded = await loadWorkflowTemplate(id, 'default')
} catch (error) {
console.error('Failed to load onboarding template:', error)
}
if (!loaded) return
try {
await controller.beginTour({ templateId: id })
entryStore.dismissGettingStarted()
} catch (error) {
// Keep the screen up so the user can retry rather than landing on a
// half-started tour behind a dismissed takeover.
console.error('Failed to start onboarding tour:', error)
}
} finally {
loadingTemplateId.value = null
}
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<GettingStartedCard
:image-src="thumbnailSrc"
:title
:loading
:testid="`getting-started-card-${template.name}`"
@select="emit('select', template.name)"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import GettingStartedCard from './GettingStartedCard.vue'
const { template, loading = false } = defineProps<{
template: TemplateInfo
loading?: boolean
}>()
const emit = defineEmits<{ select: [id: string] }>()
const { getTemplateThumbnailUrl, getTemplateTitle } = useTemplateWorkflows()
const thumbnailSrc = computed(() =>
getTemplateThumbnailUrl(template, 'default')
)
const title = computed(() => getTemplateTitle(template, 'default'))
</script>

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import OnboardingTourNudge from './OnboardingTourNudge.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const meta: Meta<typeof OnboardingTourNudge> = {
title: 'Renderer/OnboardingTour/OnboardingTourNudge',
component: OnboardingTourNudge,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
const store = useOnboardingTourStore()
store.resultMedia = { url: SAMPLE_IMAGE, kind: 'image' }
store.showNudge()
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { OnboardingTourNudge },
template: '<OnboardingTourNudge />'
})
}

View File

@@ -0,0 +1,239 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
const mocks = vi.hoisted(() => ({
show: vi.fn(),
trackExploreTemplatesClicked: vi.fn(),
trackNudgeShown: vi.fn()
}))
// A reactive backing so the component's modal-close watch actually fires, the
// same way the real store's `isDialogOpen` reads a reactive dialog stack.
const openDialogs = ref<string[]>([])
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: mocks.show })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackOnboardingTourExploreTemplatesClicked:
mocks.trackExploreTemplatesClicked,
trackOnboardingTourNudgeShown: mocks.trackNudgeShown
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
isDialogOpen: (key: string) => openDialogs.value.includes(key)
})
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import OnboardingTourNudge from './OnboardingTourNudge.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderNudge() {
// No appear delay: these assert what the nudge shows, not when it fades in.
return render(OnboardingTourNudge, {
props: { appearDelayMs: 0 },
global: { plugins: [i18n] }
})
}
const nudgeTitle = enMessages.onboardingTour.nudge.title
describe('OnboardingTourNudge', () => {
let store: ReturnType<typeof useOnboardingTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useOnboardingTourStore()
mocks.show.mockReset()
mocks.trackExploreTemplatesClicked.mockReset()
mocks.trackNudgeShown.mockReset()
openDialogs.value = []
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing until the nudge is requested', () => {
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('shows the post-run prompt once the store requests it', async () => {
renderNudge()
store.showNudge()
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
describe('appear delay', () => {
afterEach(() => {
vi.useRealTimers()
})
it('holds the nudge back so the fresh result gets a beat on its own', async () => {
vi.useFakeTimers()
render(OnboardingTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1999)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1)
expect(screen.getByText(nudgeTitle)).toBeInTheDocument()
})
it('never appears when the nudge is withdrawn inside the delay', async () => {
// Dismissing during the wait must cancel the pending appearance, not fire late.
vi.useFakeTimers()
render(OnboardingTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
store.dismissNudge()
await vi.advanceTimersByTimeAsync(5000)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('reports the nudge as shown only when it actually appears', async () => {
vi.useFakeTimers()
render(OnboardingTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackNudgeShown).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackNudgeShown).toHaveBeenCalledOnce()
})
})
it('leads with the generated image when the result media is an image', async () => {
renderNudge()
store.resultMedia = { url: 'result.png', kind: 'image' }
store.showNudge()
const image = await screen.findByRole('img', { name: nudgeTitle })
expect(image).toHaveAttribute('src', 'result.png')
})
it('leads with a looping video when the result media is a video', async () => {
renderNudge()
store.resultMedia = { url: 'result.mp4', kind: 'video' }
store.showNudge()
const video = await screen.findByTestId('onboarding-nudge-video')
expect(video).toHaveAttribute('src', 'result.mp4')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('opens the template library and reports the click on Explore templates', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.explore
})
)
expect(mocks.show).toHaveBeenCalledOnce()
expect(mocks.trackExploreTemplatesClicked).toHaveBeenCalledOnce()
})
it('permanently dismisses on Not now and does not resurface', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A later trigger must not bring it back this session.
store.showNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('defers past the open upgrade modal and surfaces once it closes', async () => {
openDialogs.value = ['free-tier-info']
// showNudge while the modal is open must defer, not overlap the paywall.
store.showNudge()
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
openDialogs.value = []
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
it('stays hidden on modal close when the nudge was never requested', async () => {
openDialogs.value = ['subscription-required']
renderNudge()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('surfaces the deferred nudge only once across repeated modal cycles', async () => {
openDialogs.value = ['free-tier-info']
store.showNudge()
renderNudge()
const user = userEvent.setup()
openDialogs.value = []
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A second upgrade-modal cycle must not resurface the dismissed nudge:
// the arm was consumed on the first surfacing and dismissal is permanent.
openDialogs.value = ['free-tier-info']
await nextTick()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,123 @@
<template>
<div
v-if="shown"
role="status"
class="pointer-events-auto fixed right-0 bottom-0 z-1000 flex w-80 animate-in flex-col overflow-hidden rounded-tl-xl border-t border-l border-border-default/50 bg-base-background shadow-lg duration-500 fade-in-0"
>
<div class="relative h-50 w-full bg-secondary-background">
<video
v-if="media?.kind === 'video'"
:src="media.url"
data-testid="onboarding-nudge-video"
class="size-full object-cover"
autoplay
muted
loop
playsinline
/>
<img
v-else
:src="media?.url ?? FALLBACK_MEDIA"
:alt="t('onboardingTour.nudge.title')"
class="size-full object-cover"
/>
<Button
class="absolute top-2 right-2 size-8 opacity-50 hover:opacity-100"
variant="secondary"
size="icon"
:aria-label="t('g.close')"
@click="store.dismissNudge()"
>
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
</Button>
</div>
<div
class="flex flex-col gap-2 border-t border-border-default px-4 pt-6 pb-4"
>
<p class="m-0 text-sm/5 font-bold text-base-foreground">
{{ t('onboardingTour.nudge.title') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ t('onboardingTour.nudge.body') }}
</p>
</div>
<div class="flex items-center justify-end gap-4 px-4 pb-4">
<Button
variant="link"
size="unset"
class="h-6 text-sm font-normal"
@click="store.dismissNudge()"
>
{{ t('onboardingTour.nudge.dismiss') }}
</Button>
<Button
variant="inverted"
size="lg"
class="font-normal"
@click="onExplore"
>
{{ t('onboardingTour.nudge.explore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import {
isUpgradeModalOpen,
useOnboardingTourStore
} from './onboardingTourStore'
const FALLBACK_MEDIA = '/assets/images/og-image.png'
/** Delayed, so the fresh result is seen before the nudge fades in over it. */
const { appearDelayMs = 1500 } = defineProps<{ appearDelayMs?: number }>()
const { t } = useI18n()
const store = useOnboardingTourStore()
const { resultMedia: media } = storeToRefs(store)
const shouldShow = computed(() => store.shouldShowNudge)
const shown = ref(false)
const { start: startAppearDelay, stop: cancelAppearDelay } = useTimeoutFn(
() => {
shown.value = true
useTelemetry()?.trackOnboardingTourNudgeShown?.()
},
() => appearDelayMs,
{ immediate: false }
)
watch(shouldShow, (visible) => {
cancelAppearDelay()
shown.value = false
if (visible) startAppearDelay()
})
// The run-step gate arms the nudge behind the upgrade modal; surface it only once
// that modal closes, so the two never overlap.
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
watch(upgradeModalOpen, (open, wasOpen) => {
if (wasOpen && !open && store.nudgeArmed) store.showNudge()
})
function onExplore() {
useWorkflowTemplateSelectorDialog().show('command')
useTelemetry()?.trackOnboardingTourExploreTemplatesClicked?.()
store.dismissNudge()
}
</script>

View File

@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { toNodeId } from '@/types/nodeId'
import OnboardingTourOverlay from './OnboardingTourOverlay.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
import type { TourStep } from './tourSequence'
interface StoryArgs {
stepIndex: number
revealedCount: number
}
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const steps: TourStep[] = [
{ kind: 'upload', nodeId: toNodeId(1) },
{
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
},
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(4), mediaKind: 'image' }
]
const meta: Meta<StoryArgs> = {
title: 'Renderer/OnboardingTour/OnboardingTourOverlay',
component: OnboardingTourOverlay,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
stepIndex: { control: { type: 'number', min: 0, max: 3 } },
revealedCount: { control: { type: 'number', min: 0, max: 4 } }
},
decorators: [
(_, context) => {
const store = useOnboardingTourStore()
store.phase = 'active'
store.steps = steps
store.stepIndex = context.args.stepIndex
store.revealedNodeIds = new Set(
Array.from({ length: context.args.revealedCount }, (_, i) =>
toNodeId(i + 1)
)
)
const activeStep = steps[context.args.stepIndex]
store.resultMedia =
activeStep?.kind === 'result'
? { url: SAMPLE_IMAGE, kind: activeStep.mediaKind ?? 'image' }
: null
// Spotlight geometry needs a live litegraph canvas (absent in Storybook),
// so holes are exercised by the unit test; this catalogs the coach-mark.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { OnboardingTourOverlay },
template: '<OnboardingTourOverlay />'
})
export const FirstStep: Story = {
args: { stepIndex: 0, revealedCount: 1 },
render
}
export const MultiReveal: Story = {
args: { stepIndex: 1, revealedCount: 2 },
render
}
export const LastStep: Story = {
args: { stepIndex: 3, revealedCount: 1 },
render
}

View File

@@ -0,0 +1,554 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { toNodeId } from '@/types/nodeId'
import type * as adapterModule from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import type { TourStep } from './tourSequence'
type AdapterModule = typeof adapterModule
const mocks = vi.hoisted(() => ({
maskRects: [] as ScreenRect[],
// Optional per-node-id rects: when set, `maskRectsFor` returns one rect per id
// that has an entry, so the revealed (holes) and spotlit (rings) calls resolve
// by which ids they carry — not by how many. Falls back to `maskRects`.
rectsById: null as Record<string, ScreenRect> | null,
focusNodes: vi.fn(),
// A camera already at rest: these assert what a step renders, not the settle
// machine (covered against the real `trackSettle` in the adapter's own tests).
transformKey: 'settled' as string | null,
viewport: { left: 0, top: 0, width: 1440, height: 900 } as ScreenRect,
controller: {
end: vi.fn(),
back: vi.fn(),
advance: vi.fn()
}
}))
// Only the litegraph-backed reads are stubbed; the pure geometry helpers stay real,
// so a test that pans a target off screen exercises the same code prod does.
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<AdapterModule>()),
// Zero so a step's copy is gated only on the camera settling, never on a wait.
TOUR_FOCUS_DURATION_MS: 0,
// Return a fresh array (like prod) so a caller pushing to it can't grow the mock.
maskRectsFor: (ids: unknown[]) =>
mocks.rectsById === null
? [...mocks.maskRects]
: (ids as { toString(): string }[])
.map((id) => mocks.rectsById?.[String(id)])
.filter((r): r is ScreenRect => r !== undefined),
focusNodes: mocks.focusNodes,
canvasViewport: () => mocks.viewport,
canvasTransformKey: () => mocks.transformKey,
canvasElement: () => null
}))
vi.mock('./useOnboardingTourController', () => ({
useOnboardingTourController: () => mocks.controller
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import OnboardingTourOverlay from './OnboardingTourOverlay.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
import enMessages from '@/locales/en/main.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const promptStep: TourStep = {
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
}
const runStep: TourStep = { kind: 'run', nodeId: null }
const videoResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'video'
}
const imageResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'image'
}
function rect(left: number): ScreenRect {
return { left, top: 0, width: 100, height: 50 }
}
function renderOverlay() {
return render(OnboardingTourOverlay, { global: { plugins: [i18n] } })
}
describe('OnboardingTourOverlay', () => {
let store: ReturnType<typeof useOnboardingTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useOnboardingTourStore()
mocks.maskRects = []
mocks.rectsById = null
mocks.transformKey = 'settled'
mocks.viewport = { left: 0, top: 0, width: 1440, height: 900 }
mocks.focusNodes.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing while the tour is idle', () => {
renderOverlay()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('names the dialog for assistive technology when active', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep]
renderOverlay()
expect(
await screen.findByRole('dialog', { name: 'Getting started tour' })
).toBeInTheDocument()
})
it('draws a focus ring for each spotlit node when active', async () => {
mocks.maskRects = [rect(0), rect(200)]
store.phase = 'active'
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(2)
})
})
it('rings only the current step, not every revealed node', async () => {
// At the prompt step both the upload node (1) and the prompt host (2) are
// revealed, but only the prompt host is spotlit. Keying rects by id proves
// the ring set is the current step's targets, not the accumulated reveals.
const uploadStep: TourStep = { kind: 'upload', nodeId: toNodeId(1) }
mocks.rectsById = { '1': rect(0), '2': rect(200) }
store.phase = 'active'
store.steps = [uploadStep, promptStep, runStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
// Holes cover both revealed nodes (1 & 2); rings cover only spotlit node 2.
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('shows the step counter derived from stepIndex and the step total', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
renderOverlay()
expect(await screen.findByText('2 of 3')).toBeInTheDocument()
})
it.for([
{
name: 'renders the t2i prompt copy',
shape: 't2i',
body: 'Your image gets built from this description. Try anything you like.'
},
{
name: 'renders the i2v prompt copy',
shape: 'i2v',
body: 'The image sets the scene. This tells it what happens next.'
},
{
name: 'renders the image-edit prompt copy',
shape: 'image-edit',
body: 'Your image stays as it is. Describe what you want different.'
},
{
name: 'renders the other prompt copy',
shape: 'other',
body: 'This is your prompt. Change it to change your result.'
}
] as const)('$name', async ({ shape, body }) => {
store.phase = 'active'
store.steps = [{ ...promptStep, shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it.for([
{
name: 'renders the i2v upload copy',
shape: 'i2v',
body: 'This picture is your first frame. Swap in your own whenever you like.'
},
{
name: 'renders the image-edit upload copy',
shape: 'image-edit',
body: "This is the picture you'll edit. Swap in your own whenever you like."
},
{
name: 'renders the other upload copy',
shape: 'other',
body: 'This image feeds the workflow. Swap in your own whenever you like.'
}
] as const)('$name', async ({ shape, body }) => {
store.phase = 'active'
store.steps = [{ kind: 'upload', nodeId: toNodeId(1), shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it('renders video result copy for a video sink once the media is captured', async () => {
store.phase = 'active'
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:video-output', kind: 'video' }
renderOverlay()
expect(
await screen.findByText(
'Your new video lands right here — ready to download or share.'
)
).toBeInTheDocument()
})
it('keeps result media out of the coach-mark (it lands on the canvas node)', async () => {
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:image-output', kind: 'image' }
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.queryByTestId('onboarding-result-video')).toBeNull()
})
it('ends the tour with skip when the Skip control is pressed', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep]
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(mocks.controller.end).toHaveBeenCalledWith('skip')
})
it('advances on Next before the last step', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Next' }))
expect(mocks.controller.advance).toHaveBeenCalledOnce()
})
it('shows Complete and finishes the tour on the last step', async () => {
store.phase = 'active'
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Finish' }))
expect(mocks.controller.end).toHaveBeenCalledWith('done')
})
it('hides Back on the first step', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
renderOverlay()
await screen.findByRole('button', { name: 'Next' })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('shows Back after the first step and goes back on press', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Back' }))
expect(mocks.controller.back).toHaveBeenCalledOnce()
})
it('points the decorative agent cursor at the focused target', async () => {
mocks.maskRects = [rect(0)]
store.phase = 'active'
store.steps = [promptStep, runStep]
renderOverlay()
const cursor = await screen.findByTestId('onboarding-cursor')
expect(cursor).toHaveAttribute('aria-hidden', 'true')
})
it('hides the cursor but keeps the coach-mark and Skip reachable with no target', async () => {
mocks.maskRects = []
store.phase = 'active'
store.steps = [promptStep, runStep]
renderOverlay()
expect(await screen.findByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
})
it('offers no Next escape on the Run step (the run is the only way forward)', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await screen.findByText('Press Run to start generating your result')
expect(screen.queryByRole('button', { name: 'Next' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull()
// Skip and Back remain so the user is never trapped.
expect(screen.getByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Back' })).toBeVisible()
})
it('lights the action bar on the Result step without ringing it', async () => {
const actionbar = document.createElement('div')
actionbar.setAttribute('data-testid', 'comfy-actionbar')
actionbar.getBoundingClientRect = () =>
({ left: 5, top: 5, width: 80, height: 8 }) as DOMRect
document.body.append(actionbar)
mocks.maskRects = [rect(0)] // the sink node
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
try {
renderOverlay()
// Holes: sink node + action bar. Ring: the sink node only.
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-hole')).toHaveLength(2)
})
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
} finally {
actionbar.remove()
}
})
it('spotlights only the sink on the Result step when no run bar is present', async () => {
mocks.maskRects = [rect(0)]
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('keeps the result copy and shows a generating indicator while the run is live', async () => {
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = false
renderOverlay()
expect(
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
).toBeInTheDocument()
expect(await screen.findByText('Generating…')).toBeInTheDocument()
})
it('stops generating once the run reports, even if no media was captured', async () => {
// The capture polls the sink and gives up silently on timeout. Hanging the
// spinner off the media left it running forever when no URL ever landed.
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('stops generating once the media lands', async () => {
store.phase = 'active'
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:result', kind: 'image' }
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('hides Skip and Back on the Result step so Complete is the only exit', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 2
renderOverlay()
expect(await screen.findByRole('button', { name: 'Finish' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('hides the ring and holds the coach-mark when the target is panned off screen', async () => {
// The user can pan the spotlit node out of view mid-step. The ring must not be
// drawn off screen, and the mark must hold rather than chase a target that
// isn't there.
mocks.maskRects = [rect(0)]
store.phase = 'active'
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
mocks.maskRects = [{ left: -900, top: -900, width: 100, height: 50 }]
await vi.waitFor(() => {
expect(screen.queryByTestId('onboarding-spotlight')).toBeNull()
})
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
})
it('keeps the coach-mark on screen when the target fills the viewport', async () => {
// A zoomed-in node can be larger than the canvas region; the mark must still be
// fully placed inside it rather than spilling off an edge.
mocks.viewport = { left: 0, top: 40, width: 800, height: 600 }
mocks.maskRects = [{ left: 0, top: 40, width: 800, height: 600 }]
store.phase = 'active'
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
const mark = await screen.findByTestId('onboarding-coach-mark')
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
const top = Number.parseFloat(mark.style.top)
const left = Number.parseFloat(mark.style.left)
expect(top).toBeGreaterThanOrEqual(mocks.viewport.top)
expect(left).toBeGreaterThanOrEqual(mocks.viewport.left)
})
it('zooms in once, then pans without re-zooming on later steps', async () => {
// Reserving room on the first framing sizes the node so the mark fits beside
// it; later steps pass none, which pans at the scale already reached.
mocks.maskRects = [rect(0)]
store.phase = 'active'
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toEqual(
expect.objectContaining({ width: expect.any(Number) })
)
mocks.focusNodes.mockClear()
store.stepIndex = 2
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toBeUndefined()
})
it('does not move the camera on the Run step, which points at the toolbar', async () => {
store.phase = 'active'
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
mocks.focusNodes.mockClear()
store.stepIndex = 1
await screen.findByText('Press Run to start generating your result')
expect(mocks.focusNodes).not.toHaveBeenCalled()
})
it('spotlights the toolbar Run button on the Run step', async () => {
const runButton = document.createElement('div')
runButton.setAttribute('data-testid', 'queue-button')
runButton.getBoundingClientRect = () =>
({ left: 10, top: 20, width: 40, height: 16 }) as DOMRect
document.body.append(runButton)
store.phase = 'active'
store.steps = [runStep]
try {
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
} finally {
runButton.remove()
}
})
})

View File

@@ -0,0 +1,412 @@
<template>
<Teleport v-if="isActive" to="body">
<div
class="pointer-events-none fixed inset-0 z-3000"
role="dialog"
:aria-label="t('onboardingTour.overlayLabel')"
>
<svg class="absolute inset-0 size-full" aria-hidden="true">
<defs>
<mask id="onboarding-tour-spotlight">
<rect width="100%" height="100%" fill="white" />
<rect
v-for="(hole, i) in visibleHoleRects"
:key="i"
data-testid="onboarding-hole"
:x="hole.left"
:y="hole.top"
:width="hole.width"
:height="hole.height"
rx="12"
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
:class="
cn(
'fill-base-background/95',
!reduceMotion && 'transition-opacity duration-500 ease-out',
revealed ? 'opacity-100' : 'opacity-0'
)
"
mask="url(#onboarding-tour-spotlight)"
/>
</svg>
<div
v-for="(hole, i) in visibleSpotRects"
:key="i"
data-testid="onboarding-spotlight"
:class="
cn(
'absolute border-node-component-outline',
!reduceMotion && 'transition-opacity duration-300 ease-out',
revealed ? 'opacity-100' : 'opacity-0',
isRunStep ? 'rounded-lg border' : 'rounded-xl border-2'
)
"
:style="ringStyle(hole)"
/>
<div
ref="bubbleRef"
data-testid="onboarding-coach-mark"
:class="
cn(
'absolute flex h-fit w-full max-w-xs flex-col gap-6 rounded-2xl bg-secondary-background p-5 shadow-interface',
!reduceMotion &&
(markGlides
? 'transition-[top,left,opacity] ease-in-out'
: 'transition-opacity duration-300 ease-out'),
copyVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)
"
:style="bubbleStyle"
tabindex="-1"
aria-live="polite"
>
<i
v-if="placement"
data-testid="onboarding-cursor"
:class="
cn(
'absolute icon-[lucide--mouse-pointer-2] size-4 text-base-foreground drop-shadow-md',
cursorEdgeClass
)
"
aria-hidden="true"
/>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-base-foreground opacity-50">
{{
t('onboardingTour.stepCounter', {
current: stepIndex + 1,
total: totalSteps
})
}}
</span>
<span
v-if="isGenerating"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<DotSpinner :size="12" />
{{ t('onboardingTour.generating') }}
</span>
</div>
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ t(copy.title) }}
</h2>
<p class="m-0 text-sm text-muted-foreground">{{ t(copy.body) }}</p>
</div>
<div class="flex items-center justify-between">
<Button
v-if="!isResultStep"
variant="textonly"
size="md"
class="font-normal"
@click="controller.end('skip')"
>
{{ t('onboardingTour.skip') }}
</Button>
<div class="ml-auto flex items-center gap-2">
<Button
v-if="stepIndex > 0 && !isResultStep"
variant="secondary"
size="md"
class="gap-1 border border-muted-background px-3 py-2 font-normal"
@click="controller.back()"
>
<i class="icon-[lucide--arrow-left] size-4" aria-hidden="true" />
{{ t('onboardingTour.back') }}
</Button>
<Button
v-if="showNextButton"
variant="inverted"
size="md"
class="gap-1 px-3 py-2 font-normal"
@click="onNext"
>
<i
v-if="isLastStep"
class="icon-[lucide--check] size-4"
aria-hidden="true"
/>
{{
isLastStep
? t('onboardingTour.complete')
: t('onboardingTour.next')
}}
<i
v-if="!isLastStep"
class="icon-[lucide--arrow-right] size-4"
aria-hidden="true"
/>
</Button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
useElementBounding,
usePreferredReducedMotion,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import {
COACH_MARK_GAP,
canvasElement,
coachMarkPosition,
focusNodes,
rectIntersectsViewport
} from './canvasSpotlightAdapter'
import type { CoachMarkEdge, ScreenRect } from './canvasSpotlightAdapter'
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
import { useOnboardingTourController } from './useOnboardingTourController'
import { useOnboardingTourStore } from './onboardingTourStore'
import { useTourSpotlightRects } from './useTourSpotlightRects'
import type { TourStep } from './tourSequence'
const { t } = useI18n()
const store = useOnboardingTourStore()
const controller = useOnboardingTourController()
const {
phase,
stepIndex,
revealedNodeIds,
spotlitNodeIds,
currentStep,
totalSteps,
resultMedia,
runFinished,
tourRunId
} = storeToRefs(store)
const isActive = computed(() => phase.value === 'active')
const isLastStep = computed(() => stepIndex.value >= totalSteps.value - 1)
const isRunStep = computed(() => currentStep.value?.kind === 'run')
const isResultStep = computed(() => currentStep.value?.kind === 'result')
/** Gated on the run reporting, not on media: a timed-out capture must not spin forever. */
const isGenerating = computed(
() => isResultStep.value && !runFinished.value && !resultMedia.value
)
/** The Run step advances on click, so it offers no Next. */
const showNextButton = computed(() => !isRunStep.value)
function stepCopyKey(step: TourStep): { title: string; body: string } {
const base = `onboardingTour.step.${step.kind}`
switch (step.kind) {
case 'upload':
case 'prompt': {
const shape = step.shape ?? 'other'
return { title: `${base}.${shape}.title`, body: `${base}.${shape}.body` }
}
case 'run':
return { title: `${base}.title`, body: `${base}.body` }
case 'result': {
const media = step.mediaKind ?? 'image'
return { title: `${base}.${media}.title`, body: `${base}.${media}.body` }
}
}
}
const copy = computed(() =>
currentStep.value ? stepCopyKey(currentStep.value) : { title: '', body: '' }
)
function onNext() {
if (isLastStep.value) {
controller.end('done')
} else {
void controller.advance()
}
}
// Called at setup, not inside the computed: a computed getter has no effect scope,
// so the matchMedia listener VueUse registers there would never be disposed.
const preferredMotion = usePreferredReducedMotion()
const reduceMotion = computed(() => preferredMotion.value === 'reduce')
const bubbleRef = useTemplateRef<HTMLElement>('bubbleRef')
const { width: bubbleWidth, height: bubbleHeight } =
useElementBounding(bubbleRef)
/** True once the tour has zoomed in; later steps pan at that scale. */
const hasZoomed = ref(false)
/**
* The side the mark sits on, held for the step so it doesn't jump as the user pans.
* Latched only once the framing settles, so the choice reflects the final view.
*/
const lockedEdge = ref<CoachMarkEdge | undefined>()
/**
* Frame the current step, reserving the mark's real footprint on the first framing
* so the node is sized to leave room for it. Later steps pan at that scale.
*/
function frameTarget() {
const reserve = {
width: bubbleWidth.value + COACH_MARK_GAP,
height: bubbleHeight.value + COACH_MARK_GAP
}
focusNodes([...spotlitNodeIds.value], hasZoomed.value ? undefined : reserve)
hasZoomed.value = true
}
const choreography = useTourChoreography({
reduceMotion,
frameTarget,
isStatic: isRunStep
})
const { revealed, copyVisible, cameraSettled, markGlides } = choreography
const rects = useTourSpotlightRects({
isRunStep,
isResultStep,
revealedNodeIds,
spotlitNodeIds,
onFrame: choreography.sampleFrame
})
const { viewport, focusRect } = rects
/** Holes are cut only once the steps begin, so the intro preview reads undimmed. */
const visibleHoleRects = computed(() =>
revealed.value ? rects.holeRects.value : []
)
const visibleSpotRects = computed(() =>
revealed.value ? rects.visibleSpotRects.value : []
)
watch(
[isActive, stepIndex, tourRunId],
([active, , runId], previous) => {
if (!active) {
choreography.endTour()
hasZoomed.value = false
return
}
// A fresh tour, not a step change. Keyed to the run id rather than an
// idle→active edge: `start()` passes through idle within one tick, so the
// watcher only ever sees active→active and would carry the last tour's zoom in.
if (previous?.[2] === runId) {
choreography.resetStep()
lockedEdge.value = undefined
choreography.beginStep()
return
}
hasZoomed.value = false
lockedEdge.value = undefined
choreography.openTour()
},
{ immediate: true }
)
watch(
[isActive, revealedNodeIds, spotlitNodeIds, currentStep],
([active]) => (active ? rects.start() : rects.stop()),
{ immediate: true }
)
// animateToBounds captures the canvas size when the tween starts, so a resize
// mid-flight lands the camera short. Re-frame against the new size.
useResizeObserver(
computed(() => (isActive.value ? canvasElement() : null)),
() => {
if (!isActive.value || choreography.isAwaitingSettle()) return
if (!revealed.value || isRunStep.value || !hasZoomed.value) return
focusNodes([...spotlitNodeIds.value])
}
)
// Routing away mid-tour must end the tour, so the controller's grace timer can't
// outlive the UI it drives.
onUnmounted(() => {
choreography.clearTimers()
if (isActive.value) controller.end('skip')
})
/** Px the node ring sits outside the box, mirroring litegraph's selection overlay. */
const NODE_RING_OFFSET = 3
/** The node ring frames the box from outside; the Run button ring hugs it tightly. */
function ringStyle(rect: ScreenRect) {
const offset = isRunStep.value ? 0 : NODE_RING_OFFSET
return {
left: `${rect.left - offset}px`,
top: `${rect.top - offset}px`,
width: `${rect.width + offset * 2}px`,
height: `${rect.height + offset * 2}px`
}
}
/** Null while the target is off-screen, so the mark holds its last placement. */
const placement = computed(() => {
const rect = focusRect.value
if (!rect) return null
if (!rectIntersectsViewport(rect, viewport.value)) return null
return coachMarkPosition(
rect,
{ width: bubbleWidth.value, height: bubbleHeight.value },
viewport.value,
lockedEdge.value
)
})
// Once latched, the side only changes if the user pans far enough to break its fit.
watch([placement, cameraSettled], ([pos, settled]) => {
if (settled && pos) lockedEdge.value = pos.pointerEdge
})
// Shares MARK_GLIDE_MS with the camera delay, so the mark lands before it starts.
const bubbleStyle = computed(() => {
const glide = markGlides.value
? { transitionDuration: `${MARK_GLIDE_MS}ms` }
: {}
return placement.value
? {
...glide,
position: 'fixed' as const,
top: `${placement.value.top}px`,
left: `${placement.value.left}px`
}
: {
...glide,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
})
/** Floats the cursor mid-gap on the target-facing edge, tip rotated toward the node. */
const CURSOR_EDGE_CLASS: Record<CoachMarkEdge, string> = {
top: '-top-7 left-1/2 -translate-x-1/2 rotate-45',
bottom: '-bottom-7 left-1/2 -translate-x-1/2 -rotate-[135deg]',
left: '-left-7 top-1/2 -translate-y-1/2 -rotate-45',
right: '-right-7 top-1/2 -translate-y-1/2 rotate-[135deg]'
}
const cursorEdgeClass = computed(() =>
placement.value ? CURSOR_EDGE_CLASS[placement.value.pointerEdge] : ''
)
</script>

View File

@@ -0,0 +1,17 @@
import fs from 'node:fs'
import path from 'node:path'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { CuratedTemplateId } from '../roleResolver'
const TEMPLATES_DIR = path.join(import.meta.dirname, 'templates')
/**
* Loads a curated template's real serialized workflow from the committed
* fixtures. Used to prove the resolver's heuristics against ground truth.
*/
export function loadTemplateWorkflow(id: CuratedTemplateId): ComfyWorkflowJSON {
const file = path.join(TEMPLATES_DIR, `${id}.json`)
return JSON.parse(fs.readFileSync(file, 'utf-8')) as ComfyWorkflowJSON
}

View File

@@ -0,0 +1,526 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
const mocks = vi.hoisted(() => ({
canvas: null as unknown,
currentGraph: null as LGraph | null
}))
vi.mock('@/scripts/app', () => ({
app: {
get canvas() {
return mocks.canvas
}
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get currentGraph() {
return mocks.currentGraph
}
})
}))
import {
INITIAL_SETTLE,
TOUR_FOCUS_DURATION_MS,
canvasTransformValid,
coachMarkPosition,
focusFillFor,
focusNodes,
maskRectsFor,
nodeClientRect,
nodesPresent,
rectIntersectsViewport,
trackSettle
} from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
function fakeCanvas(options: {
offset?: [number, number]
scale?: number
clientLeft?: number
clientTop?: number
}) {
const { offset = [0, 0], scale = 1, clientLeft = 0, clientTop = 0 } = options
return {
canvas: {
getBoundingClientRect: () =>
({ left: clientLeft, top: clientTop }) as DOMRect
},
ds: { offset, scale }
}
}
function fakeNode(
id: number,
boundingRect: [number, number, number, number]
): LGraphNode {
return fromAny<LGraphNode, unknown>({ id: toNodeId(id), boundingRect })
}
describe('canvasSpotlightAdapter', () => {
beforeEach(() => {
mocks.canvas = fakeCanvas({})
mocks.currentGraph = null
})
afterEach(() => {
vi.clearAllMocks()
})
describe('nodeClientRect', () => {
it('maps graph coords to client coords at scale 1, no offset', () => {
mocks.canvas = fakeCanvas({ clientLeft: 100, clientTop: 50 })
const node = fakeNode(1, [10, 20, 200, 80])
expect(nodeClientRect(node)).toEqual({
left: 110,
top: 70,
width: 200,
height: 80
})
})
it('applies pan offset and zoom scale', () => {
mocks.canvas = fakeCanvas({
clientLeft: 0,
clientTop: 0,
offset: [5, 10],
scale: 2
})
const node = fakeNode(1, [10, 20, 100, 40])
// left = 0 + (10 + 5) * 2 = 30; top = (20 + 10) * 2 = 60; size scaled ×2
expect(nodeClientRect(node)).toEqual({
left: 30,
top: 60,
width: 200,
height: 80
})
})
it('returns null when the canvas is unavailable', () => {
mocks.canvas = null
expect(nodeClientRect(fakeNode(1, [0, 0, 10, 10]))).toBeNull()
})
})
describe('maskRectsFor', () => {
it('resolves node ids through the current graph, hugging each node box', () => {
const node = fakeNode(7, [0, 0, 100, 50])
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
})
const rects = maskRectsFor([toNodeId(7)])
expect(rects).toHaveLength(1)
expect(rects[0]).toEqual({ left: 0, top: 0, width: 100, height: 50 })
})
it('drops ids that do not resolve, never throwing', () => {
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
expect(maskRectsFor([toNodeId(99)])).toEqual([])
})
it('drops ids that resolve to undefined without reading boundingRect', () => {
// getNodeById returns undefined (not null) for ids absent from the graph —
// e.g. a nested executing node — which must not crash on `.boundingRect`.
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: () => undefined as unknown as null
})
expect(() => maskRectsFor([toNodeId(99)])).not.toThrow()
expect(maskRectsFor([toNodeId(99)])).toEqual([])
})
it('returns empty when there is no current graph', () => {
mocks.currentGraph = null
expect(maskRectsFor([toNodeId(1)])).toEqual([])
})
})
describe('focusNodes', () => {
it('pans to the resolved nodes bounds without re-zooming when no room is reserved', () => {
const animate = vi.fn()
const node = fakeNode(3, [0, 0, 100, 100])
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(3) ? node : null)
})
focusNodes([toNodeId(3)])
// createBounds pads the [0,0,100,100] node by 10 on every side; zoom 0 keeps
// the current scale, so later steps pan rather than re-zoom.
expect(animate).toHaveBeenCalledWith([-10, -10, 120, 120], {
zoom: 0,
duration: TOUR_FOCUS_DURATION_MS
})
})
it('does nothing when no nodes resolve', () => {
const animate = vi.fn()
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
focusNodes([toNodeId(99)])
expect(animate).not.toHaveBeenCalled()
})
})
describe('nodesPresent', () => {
it('is true only when every id resolves on the current graph', () => {
const node = fakeNode(7, [0, 0, 10, 10])
mocks.currentGraph = fromPartial<LGraph>({
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
})
expect(nodesPresent([toNodeId(7)])).toBe(true)
expect(nodesPresent([toNodeId(7), toNodeId(8)])).toBe(false)
})
it('is false when there is no current graph', () => {
mocks.currentGraph = null
expect(nodesPresent([toNodeId(1)])).toBe(false)
})
})
describe('rectIntersectsViewport', () => {
const viewport = { left: 0, top: 100, width: 1000, height: 800 }
it.for([
['fully inside', { left: 200, top: 200, width: 50, height: 50 }, true],
[
'straddling an edge',
{ left: -20, top: 200, width: 50, height: 50 },
true
],
[
'above the region',
{ left: 200, top: 10, width: 50, height: 50 },
false
],
[
'past the right',
{ left: 1000, top: 200, width: 50, height: 50 },
false
],
[
'below the region',
{ left: 200, top: 900, width: 50, height: 50 },
false
]
] as const)('is %s => %s', ([, rect, expected]) => {
expect(rectIntersectsViewport(rect, viewport)).toBe(expected)
})
it('respects the region origin, not the window', () => {
// Sits under a 100px top bar: inside the window, outside the canvas region.
const underTopBar = { left: 200, top: 20, width: 50, height: 50 }
expect(rectIntersectsViewport(underTopBar, viewport)).toBe(false)
})
})
describe('trackSettle', () => {
const feed = (keys: (string | null)[]) =>
keys.reduce((state, key) => trackSettle(state, key), INITIAL_SETTLE)
it('does not settle while the transform keeps changing', () => {
// A camera mid-tween reports a new transform every frame.
expect(feed(['a', 'b', 'c', 'd']).settled).toBe(false)
})
it('settles once the transform holds still for two frames', () => {
expect(feed(['a', 'a']).settled).toBe(false)
expect(feed(['a', 'a', 'a']).settled).toBe(true)
})
it('un-settles when the user grabs the canvas after it landed', () => {
const landed = feed(['a', 'a', 'a'])
expect(landed.settled).toBe(true)
expect(trackSettle(landed, 'b').settled).toBe(false)
})
it('treats an absent transform as settled — there is no camera to wait for', () => {
expect(trackSettle(INITIAL_SETTLE, null).settled).toBe(true)
})
it('does not settle on a tween that pauses on one value for a single frame', () => {
// Two overlapping tweens can repeat a rounded value for one frame; requiring
// two consecutive matches keeps that from reading as a landing.
expect(feed(['a', 'b', 'b', 'c']).settled).toBe(false)
})
})
describe('focusFillFor', () => {
const viewport = { left: 0, top: 0, width: 1500, height: 900 }
const reserve = { width: 360, height: 300 }
/** Litegraph's own fit (DragAndScale), so we assert the scale it will land on. */
const scaleFor = (
fill: number,
bounds: { width: number; height: number }
) =>
Math.min(
(fill * viewport.width) / Math.max(bounds.width, 300),
(fill * viewport.height) / Math.max(bounds.height, 300),
10
)
it('caps a tiny node at the same scale a roomy one gets, never zooming further', () => {
// Both fit with room to spare, so both land on the ceiling.
const tiny = { width: 60, height: 40 }
const roomy = { width: 400, height: 300 }
const tinyScale = scaleFor(focusFillFor(tiny, viewport, reserve), tiny)
const roomyScale = scaleFor(focusFillFor(roomy, viewport, reserve), roomy)
expect(tinyScale).toBeCloseTo(roomyScale, 2)
// And the cap holds the node near life size, not blown up.
expect(tinyScale).toBeLessThanOrEqual(1.2)
})
it('shrinks a tall node until the mark still has room beside it', () => {
const tall = { width: 400, height: 1600 }
const scale = scaleFor(focusFillFor(tall, viewport, reserve), tall)
expect(scale).toBeLessThan(1)
// The node leaves a full mark's width of the region free.
expect(tall.width * scale).toBeLessThanOrEqual(
viewport.width - reserve.width
)
expect(tall.height * scale).toBeLessThanOrEqual(viewport.height)
})
it('keeps a wide node on screen by stacking the mark below it', () => {
const wide = { width: 2400, height: 300 }
const scale = scaleFor(focusFillFor(wide, viewport, reserve), wide)
expect(wide.width * scale).toBeLessThanOrEqual(viewport.width)
expect(wide.height * scale).toBeLessThanOrEqual(
viewport.height - reserve.height
)
})
it('leaves the mark room for any node, without a floor forcing overflow', () => {
const nodes = [
{ width: 240, height: 140 },
{ width: 500, height: 650 },
{ width: 600, height: 1100 },
{ width: 800, height: 1600 },
{ width: 3000, height: 2400 }
]
for (const node of nodes) {
const scale = scaleFor(focusFillFor(node, viewport, reserve), node)
const fitsBeside =
node.width * scale <= viewport.width - reserve.width &&
node.height * scale <= viewport.height
const fitsBelow =
node.width * scale <= viewport.width &&
node.height * scale <= viewport.height - reserve.height
expect(fitsBeside || fitsBelow).toBe(true)
}
})
it('adapts to the region it is given, not a fixed screen size', () => {
const node = { width: 500, height: 400 }
const small = { left: 0, top: 0, width: 800, height: 600 }
const large = { left: 0, top: 0, width: 2560, height: 1400 }
const smallScale =
(focusFillFor(node, small, reserve) * small.width) /
Math.max(node.width, 300)
const largeScale =
(focusFillFor(node, large, reserve) * large.width) /
Math.max(node.width, 300)
expect(smallScale).toBeLessThan(largeScale)
})
})
describe('coachMarkPosition', () => {
const bubble = { width: 320, height: 200 }
const viewport = { left: 0, top: 0, width: 1440, height: 900 }
it('centers below the target with room on all sides', () => {
const target = { left: 600, top: 300, width: 100, height: 60 }
// left = 600 + 50 - 160 = 490; top = 300 + 60 + 40 = 400
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
left: 490,
top: 400,
pointerEdge: 'top'
})
})
it('flips above when it would overflow the bottom edge', () => {
const target = { left: 600, top: 800, width: 100, height: 60 }
// below (900) + 200 overflows 900; flip above: 800 - 200 - 40 = 560
expect(coachMarkPosition(target, bubble, viewport).top).toBe(560)
})
it('places to the right when below/above would clip the left edge', () => {
const target = { left: 0, top: 300, width: 40, height: 40 }
// centered-below/above overflow the left edge; the right placement fits.
// The card sits to the right, so the cursor points off its left edge.
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
left: 0 + 40 + 40,
top: 300 + 20 - 100,
pointerEdge: 'left'
})
})
it('places to the left when the right edge has no room', () => {
const target = { left: 1400, top: 300, width: 40, height: 40 }
// right placement would overflow; the left placement fits.
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
left: 1400 - 320 - 40,
top: 300 + 20 - 100,
pointerEdge: 'right'
})
})
it('sits beside a zoomed-in node instead of covering it', () => {
// A node tall enough that below/above clip vertically but a side still fits
// (the realistic zoomed case) — the card must not overlap the node.
const target = { left: 500, top: 60, width: 400, height: 780 }
const pos = coachMarkPosition(target, bubble, viewport)
const overlapsTarget =
pos.left < target.left + target.width &&
pos.left + bubble.width > target.left &&
pos.top < target.top + target.height &&
pos.top + bubble.height > target.top
expect(overlapsTarget).toBe(false)
})
it('clamps to the padded corner when the bubble is larger than the viewport', () => {
// Bubble bigger than the viewport: no candidate fits, so the fallback pins
// it on screen and points from the side with the most room (here, right).
const tiny = { left: 0, top: 0, width: 300, height: 180 }
const target = { left: 100, top: 80, width: 40, height: 40 }
// Every clamp max collapses to VIEWPORT_PADDING (viewport - bubble < 0).
expect(coachMarkPosition(target, bubble, tiny)).toEqual({
left: 12,
top: 12,
pointerEdge: 'left'
})
})
/** Whether a placement sits entirely inside the region. */
const isInside = (
pos: { left: number; top: number },
box: { width: number; height: number },
region: ScreenRect
) =>
pos.left >= region.left &&
pos.top >= region.top &&
pos.left + box.width <= region.left + region.width &&
pos.top + box.height <= region.top + region.height
describe('staying inside an inset region', () => {
// The canvas sits below the top bar and beside the panels, so the mark must
// be placed against that region — measuring the whole window put it under
// the toolbar.
const inset = { left: 64, top: 100, width: 1200, height: 700 }
it('never lands under the chrome above the region', () => {
const target = { left: 400, top: 120, width: 80, height: 60 }
const pos = coachMarkPosition(target, bubble, inset)
expect(pos.top).toBeGreaterThanOrEqual(inset.top)
})
it.for([
[
'taller than the region',
{ left: 400, top: 110, width: 200, height: 900 }
],
[
'wider than the region',
{ left: 70, top: 300, width: 1400, height: 200 }
],
[
'larger on both axes',
{ left: 70, top: 110, width: 1400, height: 900 }
],
[
'hard against the top-left',
{ left: 64, top: 100, width: 40, height: 40 }
],
[
'hard against the bottom-right',
{ left: 1200, top: 750, width: 60, height: 45 }
]
] as const)('keeps the mark on screen with a target %s', ([, target]) => {
expect(
isInside(coachMarkPosition(target, bubble, inset), bubble, inset)
).toBe(true)
})
})
describe('preferred edge', () => {
const target = { left: 600, top: 300, width: 100, height: 60 }
it('holds the preferred side while it still fits', () => {
// Below fits and would win on its own; the caller's latched side wins.
expect(
coachMarkPosition(target, bubble, viewport, 'left').pointerEdge
).toBe('left')
})
it('abandons the preferred side once it stops fitting', () => {
// Nothing fits to the right of a target hard against the right edge.
const atRightEdge = { left: 1380, top: 300, width: 50, height: 60 }
expect(
coachMarkPosition(atRightEdge, bubble, viewport, 'left').pointerEdge
).not.toBe('left')
})
it('falls back to first-fit when no side is preferred', () => {
expect(coachMarkPosition(target, bubble, viewport).pointerEdge).toBe(
'top'
)
})
})
})
describe('canvasTransformValid', () => {
it('is true for a finite positive transform', () => {
mocks.canvas = fakeCanvas({ scale: 1, offset: [0, 0] })
expect(canvasTransformValid()).toBe(true)
})
it('is false when the canvas is absent', () => {
mocks.canvas = null
expect(canvasTransformValid()).toBe(false)
})
it('is false for a zero or non-finite scale', () => {
mocks.canvas = fakeCanvas({ scale: 0 })
expect(canvasTransformValid()).toBe(false)
mocks.canvas = fakeCanvas({ scale: Number.NaN })
expect(canvasTransformValid()).toBe(false)
})
it('is false for a non-finite offset', () => {
mocks.canvas = fakeCanvas({ offset: [Number.NaN, 0] })
expect(canvasTransformValid()).toBe(false)
})
})
})

View File

@@ -0,0 +1,369 @@
import { clamp } from 'es-toolkit/math'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeId } from '@/types/nodeId'
/** The toolbar Run button (queue on desktop, subscribe-to-run on cloud). */
export const RUN_BUTTON_SELECTOR =
'[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]'
/** The floating action bar the Run button sits in. */
export const ACTIONBAR_SELECTOR = '[data-testid="comfy-actionbar"]'
/** A rectangle in client (viewport) coordinates. */
export interface ScreenRect {
left: number
top: number
width: number
height: number
}
/** The box edge the cursor sits on, pointing back at the target. */
export type CoachMarkEdge = 'top' | 'bottom' | 'left' | 'right'
interface Size {
width: number
height: number
}
/** A viewport-space position for the coach-mark plus the edge that points at the target. */
interface CoachMarkPosition {
left: number
top: number
pointerEdge: CoachMarkEdge
}
/** Whether any part of `rect` is inside `viewport`. */
export function rectIntersectsViewport(
rect: ScreenRect,
viewport: ScreenRect
): boolean {
return (
rect.left < viewport.left + viewport.width &&
rect.left + rect.width > viewport.left &&
rect.top < viewport.top + viewport.height &&
rect.top + rect.height > viewport.top
)
}
/** Space between the coach-mark and the target it points at. */
export const COACH_MARK_GAP = 40
const VIEWPORT_PADDING = 12
function fitsViewport(
pos: CoachMarkPosition,
bubble: Size,
viewport: ScreenRect
) {
return (
pos.left >= viewport.left + VIEWPORT_PADDING &&
pos.top >= viewport.top + VIEWPORT_PADDING &&
pos.left + bubble.width <=
viewport.left + viewport.width - VIEWPORT_PADDING &&
pos.top + bubble.height <= viewport.top + viewport.height - VIEWPORT_PADDING
)
}
function overlaps(pos: CoachMarkPosition, bubble: Size, target: ScreenRect) {
return (
pos.left < target.left + target.width &&
pos.left + bubble.width > target.left &&
pos.top < target.top + target.height &&
pos.top + bubble.height > target.top
)
}
/** Pin a placement inside the padded viewport, so the mark is never cut off. */
function clampToViewport(
pos: CoachMarkPosition,
bubble: Size,
viewport: ScreenRect
): CoachMarkPosition {
const minLeft = viewport.left + VIEWPORT_PADDING
const minTop = viewport.top + VIEWPORT_PADDING
return {
...pos,
left: clamp(
pos.left,
minLeft,
Math.max(
minLeft,
viewport.left + viewport.width - bubble.width - VIEWPORT_PADDING
)
),
top: clamp(
pos.top,
minTop,
Math.max(
minTop,
viewport.top + viewport.height - bubble.height - VIEWPORT_PADDING
)
)
}
}
function candidatesFor(target: ScreenRect, bubble: Size): CoachMarkPosition[] {
const centerX = target.left + target.width / 2 - bubble.width / 2
const centerY = target.top + target.height / 2 - bubble.height / 2
return [
{
left: centerX,
top: target.top + target.height + COACH_MARK_GAP,
pointerEdge: 'top'
},
{
left: centerX,
top: target.top - bubble.height - COACH_MARK_GAP,
pointerEdge: 'bottom'
},
{
left: target.left + target.width + COACH_MARK_GAP,
top: centerY,
pointerEdge: 'left'
},
{
left: target.left - bubble.width - COACH_MARK_GAP,
top: centerY,
pointerEdge: 'right'
}
]
}
/**
* Place the coach-mark near `target` without covering it: below, above, right, then
* left, taking the first placement that fits and clears the target. Always clamped
* into the viewport, so the mark stays on screen even when no candidate fits.
*
* @param preferredEdge Wins while it still fits. Without it, a first-fit search would
* snap the mark between edges mid-zoom as the target grows.
*/
export function coachMarkPosition(
target: ScreenRect,
bubble: Size,
viewport: ScreenRect,
preferredEdge?: CoachMarkEdge
): CoachMarkPosition {
const candidates = candidatesFor(target, bubble)
const viable = (pos: CoachMarkPosition) =>
fitsViewport(pos, bubble, viewport) && !overlaps(pos, bubble, target)
const preferred = candidates.find((pos) => pos.pointerEdge === preferredEdge)
const placed =
(preferred && viable(preferred) ? preferred : undefined) ??
candidates.find(viable)
if (placed) return placed
// Nothing fits: the target is too big to sit clear of. Overlap its roomiest edge
// rather than spill off screen.
return clampToViewport(
candidates[freestEdgeIndex(target, viewport)],
bubble,
viewport
)
}
/** Index into `candidatesFor`'s order (below, above, right, left) with most room. */
function freestEdgeIndex(target: ScreenRect, viewport: ScreenRect): number {
const room = [
viewport.top + viewport.height - (target.top + target.height), // below
target.top - viewport.top, // above
viewport.left + viewport.width - (target.left + target.width), // right
target.left - viewport.left // left
]
return room.indexOf(Math.max(...room))
}
interface CanvasFrame {
left: number
top: number
offset: [number, number]
scale: number
}
/** Client-space origin + transform of the litegraph canvas, or null if absent. */
function canvasFrame(): CanvasFrame | null {
const canvas = app.canvas
if (!canvas) return null
const rect = canvas.canvas.getBoundingClientRect()
const { offset, scale } = canvas.ds
return { left: rect.left, top: rect.top, offset, scale }
}
/**
* The region the overlay may draw in: the canvas's client rect, not the window.
* Measuring the canvas excludes the toolbar and panels without assuming their size.
* Null when the canvas is absent or unlaid-out.
*/
export function canvasViewport(): ScreenRect | null {
const canvas = app.canvas
if (!canvas) return null
const { left, top, width, height } = canvas.canvas.getBoundingClientRect()
if (!(width > 0) || !(height > 0)) return null
return { left, top, width, height }
}
function toClient(frame: CanvasFrame, [x, y]: Point): Point {
return [
frame.left + (x + frame.offset[0]) * frame.scale,
frame.top + (y + frame.offset[1]) * frame.scale
]
}
/**
* Whether the canvas has a usable transform yet — a finite positive scale and
* finite offset. Used to gate the tour start until the just-loaded graph has
* actually laid out, so the spotlight never paints against a stale/absent frame.
*/
export function canvasTransformValid(): boolean {
const frame = canvasFrame()
return (
frame !== null &&
Number.isFinite(frame.scale) &&
frame.scale > 0 &&
Number.isFinite(frame.offset[0]) &&
Number.isFinite(frame.offset[1])
)
}
/** A node's bounding box in client coordinates, or null if the canvas is absent. */
export function nodeClientRect(node: LGraphNode): ScreenRect | null {
const frame = canvasFrame()
if (!frame) return null
const [bx, by, bw, bh] = node.boundingRect
const [left, top] = toClient(frame, [bx, by])
return { left, top, width: bw * frame.scale, height: bh * frame.scale }
}
function resolveNodes(nodeIds: NodeId[]): LGraphNode[] {
const graph = useCanvasStore().currentGraph
if (!graph) return []
return nodeIds
.map((id) => graph.getNodeById(id))
.filter((node): node is LGraphNode => node != null)
}
/** Whether every id resolves to a node on the live graph (vacuously true if none). */
export function nodesPresent(nodeIds: NodeId[]): boolean {
return resolveNodes(nodeIds).length === nodeIds.length
}
/** Client rects for the revealed nodes, hugging each node box; unresolvable ids are dropped. */
export function maskRectsFor(nodeIds: NodeId[]): ScreenRect[] {
return resolveNodes(nodeIds)
.map((node) => nodeClientRect(node))
.filter((rect): rect is ScreenRect => rect !== null)
}
/** Duration of the tour's framing animation; bounds the overlay's wait for it. */
export const TOUR_FOCUS_DURATION_MS = 450
/** Never magnify past this: the aim is a node that reads, not one that dominates. */
const MAX_FOCUS_SCALE = 0.6
/**
* The fill fraction that frames `bounds` with room left for a `reserve`-sized
* coach-mark, in the units `animateToBounds` expects.
*
* The mark sits beside the node *or* below it, never both, so each arrangement is
* costed separately and the roomier one wins. Deliberately unfloored: forcing a
* minimum scale is what pushes a big node past the viewport and strands the mark.
*/
export function focusFillFor(
bounds: Size,
viewport: ScreenRect,
reserve: Size
): number {
const width = Math.max(bounds.width, 1)
const height = Math.max(bounds.height, 1)
const freeWidth = viewport.width - COACH_MARK_GAP * 2
const freeHeight = viewport.height - COACH_MARK_GAP * 2
const beside = Math.min(
(freeWidth - reserve.width) / width,
freeHeight / height
)
const below = Math.min(
freeWidth / width,
(freeHeight - reserve.height) / height
)
const scale = Math.min(Math.max(beside, below), MAX_FOCUS_SCALE)
// Invert litegraph's fit: it takes scale = min(fillX, fillY), each solved as
// (fill * side) / max(bound, 300). To land on `scale`, the binding (smaller)
// axis must equal it — so take the larger of the two per-axis fills.
return Math.max(
(scale * Math.max(bounds.width, 300)) / viewport.width,
(scale * Math.max(bounds.height, 300)) / viewport.height
)
}
/**
* Frame the view around the given nodes. No-op when none resolve.
*
* @param reserve Space to leave for the coach-mark. Omit to pan at the current
* scale, so the tour zooms once and only pans after.
*/
export function focusNodes(nodeIds: NodeId[], reserve?: Size): void {
const nodes = resolveNodes(nodeIds)
if (nodes.length === 0) return
const bounds = createBounds(nodes)
if (!bounds) return
const viewport = canvasViewport()
const zoom =
reserve && viewport
? focusFillFor({ width: bounds[2], height: bounds[3] }, viewport, reserve)
: 0
app.canvas?.animateToBounds(bounds, {
zoom,
duration: TOUR_FOCUS_DURATION_MS
})
}
/** The canvas element, or null when it is absent — for observing its size. */
export function canvasElement(): HTMLCanvasElement | null {
return app.canvas?.canvas ?? null
}
/** Consecutive identical frames that mean the camera has stopped. */
const SETTLE_FRAMES = 2
/** How many frames the transform has held still, and whether that means settled. */
export interface SettleState {
key: string | null
frames: number
settled: boolean
}
export const INITIAL_SETTLE: SettleState = {
key: null,
frames: 0,
settled: false
}
/**
* Fold one frame's transform into the settle state. `animateToBounds` reports no
* completion and cannot be cancelled, so an unchanged transform is the only honest
* "camera stopped" signal. An absent transform counts as settled: no camera to wait for.
*/
export function trackSettle(
state: SettleState,
key: string | null
): SettleState {
if (key === null) return { key, frames: 0, settled: true }
if (key !== state.key) return { key, frames: 0, settled: false }
const frames = state.frames + 1
return { key, frames, settled: frames >= SETTLE_FRAMES }
}
/** The canvas transform as a comparable string, or null when the canvas is absent. */
export function canvasTransformKey(): string | null {
const frame = canvasFrame()
if (!frame) return null
return `${frame.scale}:${frame.offset[0]}:${frame.offset[1]}`
}

View File

@@ -0,0 +1,386 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
const restoreView = vi.hoisted(() => vi.fn())
vi.mock('./subgraphNavigation', () => ({ restoreView }))
const resolveTourRoles = vi.hoisted(() => vi.fn())
vi.mock('./roleResolution', () => ({ resolveTourRoles }))
const sinkNode = vi.hoisted(() => ({}) as object)
const resolveNode = vi.hoisted(() => vi.fn())
vi.mock('@/utils/litegraphUtil', () => ({ resolveNode }))
const getNodeImageUrls = vi.hoisted(() => vi.fn())
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ getNodeImageUrls })
}))
const isDialogOpen = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ isDialogOpen })
}))
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useOnboardingTourStore } from './onboardingTourStore'
import type { ResolvedRoles } from './tourSequence'
const workflow = {} as ComfyWorkflowJSON
const i2vRoles: ResolvedRoles = {
source: { nodeId: toNodeId(97) },
prompt: {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(93),
widgetName: 'text',
portFallback: 'prompt'
},
engine: { nodeId: toNodeId(86) },
sink: { nodeId: toNodeId(108) },
mediaKind: 'video'
}
const t2iRoles: ResolvedRoles = {
source: null,
prompt: {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(27),
widgetName: 'text',
portFallback: 'prompt'
},
engine: { nodeId: toNodeId(3) },
sink: { nodeId: toNodeId(9) },
mediaKind: 'image'
}
describe('onboardingTourStore', () => {
let store: ReturnType<typeof useOnboardingTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useOnboardingTourStore()
resolveTourRoles.mockReset()
restoreView.mockReset()
resolveNode.mockReset()
getNodeImageUrls.mockReset()
resolveNode.mockReturnValue(sinkNode)
getNodeImageUrls.mockReturnValue(['blob:sink-output'])
isDialogOpen.mockReset()
isDialogOpen.mockReturnValue(false)
})
it('start() on I2V roles builds [Upload, Prompt, Run, Result] and reveals the source', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
expect(resolveTourRoles).toHaveBeenCalledWith(workflow, undefined)
expect(store.phase).toBe('active')
expect(store.stepIndex).toBe(0)
expect(store.steps.map((s) => s.kind)).toEqual([
'upload',
'prompt',
'run',
'result'
])
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
})
it('start() passes the templateId through to the resolver', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow, 'image_z_image_turbo')
expect(resolveTourRoles).toHaveBeenCalledWith(
workflow,
'image_z_image_turbo'
)
})
it('start() on T2I roles omits the Upload step', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
expect(store.steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
// T2I opens on the prompt step, revealing its collapsed subgraph host.
expect([...store.revealedNodeIds]).toEqual([toNodeId(10)])
})
it('advance() accumulates revealed nodes so the graph builds up', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
store.advance() // → prompt (nodeId null, reveals the subgraph host)
expect(store.stepIndex).toBe(1)
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
store.advance() // → run (no node)
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
})
it('collapses to only the sink on the Result step', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
store.advance() // prompt
store.advance() // run
store.advance() // result
// The final step focuses solely on the generated output, hiding the rest.
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
})
it('spotlitNodeIds tracks only the current step while reveals accumulate', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
// Upload step: source revealed and spotlit.
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
expect([...store.spotlitNodeIds]).toEqual([toNodeId(97)])
store.advance() // → prompt (reveals the subgraph host)
// Reveals accumulate; the spotlight narrows to just the prompt host.
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
expect([...store.spotlitNodeIds]).toEqual([toNodeId(10)])
store.advance() // → run (no node)
expect([...store.spotlitNodeIds]).toEqual([])
store.advance() // → result (sink)
// Result collapses to only the sink for both spotlight and reveal.
expect([...store.spotlitNodeIds]).toEqual([toNodeId(108)])
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
})
it('advance() does not run past the last step', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
store.advance()
store.advance()
store.advance()
store.advance()
expect(store.stepIndex).toBe(store.steps.length - 1)
})
it('back() restores the prior reveal set', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
store.advance()
store.back()
expect(store.stepIndex).toBe(0)
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
})
it('back() does not run before the first step', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
store.back()
expect(store.stepIndex).toBe(0)
})
it('end() restores the view and resets to idle', () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
store.advance()
store.end()
expect(restoreView).toHaveBeenCalledOnce()
expect(store.phase).toBe('idle')
expect(store.stepIndex).toBe(0)
expect(store.resolvedRoles).toBeNull()
expect(store.steps).toEqual([])
expect(store.revealedNodeIds.size).toBe(0)
})
it('degrades to a lone Run step when prompt and sink are unresolved', () => {
resolveTourRoles.mockReturnValue({ ...t2iRoles, prompt: null, sink: null })
store.start(workflow)
// No prompt AND no sink → the machine still starts but degrades to the
// always-present Run step rather than crashing.
expect(store.steps.map((s) => s.kind)).toEqual(['run'])
expect(store.phase).toBe('active')
})
it('captureResultMedia() records the sink output URL with the resolved media kind', async () => {
resolveTourRoles.mockReturnValue(i2vRoles)
store.start(workflow)
await store.captureResultMedia()
expect(resolveNode).toHaveBeenCalledWith(toNodeId(108))
expect(getNodeImageUrls).toHaveBeenCalledWith(sinkNode)
expect(store.resultMedia).toEqual({
url: 'blob:sink-output',
kind: 'video'
})
})
it('captureResultMedia() waits for the URL to appear before recording it', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
// The cloud queue refresh fills the output just after execution_success.
getNodeImageUrls.mockReturnValueOnce(undefined)
getNodeImageUrls.mockReturnValue(['blob:late-output'])
await store.captureResultMedia()
expect(store.resultMedia).toEqual({
url: 'blob:late-output',
kind: 'image'
})
})
it('captureResultMedia() is a no-op while the tour is idle', async () => {
await store.captureResultMedia()
expect(resolveNode).not.toHaveBeenCalled()
expect(store.resultMedia).toBeNull()
})
it('captureResultMedia() discards the URL if the tour ends mid-wait', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
getNodeImageUrls.mockReturnValueOnce(undefined)
getNodeImageUrls.mockReturnValue(['blob:late-output'])
// The tour ends (user skips) during the wait; the URL then resolves, but the
// post-await phase guard must drop it rather than record into a dead tour.
const pending = store.captureResultMedia()
store.end()
await pending
expect(store.resultMedia).toBeNull()
})
it('captureResultMedia() is idempotent once the media is set', async () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
await store.captureResultMedia()
getNodeImageUrls.mockReturnValue(['blob:second-run'])
await store.captureResultMedia()
// A second success must not overwrite the first captured result.
expect(store.resultMedia).toEqual({
url: 'blob:sink-output',
kind: 'image'
})
})
it('captureResultMedia() gives up after the timeout without recording', async () => {
vi.useFakeTimers()
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
getNodeImageUrls.mockReturnValue(undefined)
const pending = store.captureResultMedia()
await vi.runAllTimersAsync()
await pending
expect(store.resultMedia).toBeNull()
vi.useRealTimers()
})
it('start() clears a previous tours finished run so the next one can generate', () => {
// The flag drives the Result step's "Generating…"; carried over, a second tour
// would show its result as already done.
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
store.runFinished = true
store.start(workflow)
expect(store.runFinished).toBe(false)
})
it('showNudge() surfaces the post-run nudge', () => {
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the nudge visible after the tour ends so it outlives the run', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
store.showNudge()
store.end()
expect(store.shouldShowNudge).toBe(true)
})
it('keeps the captured media after the tour ends so the nudge still shows it', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.start(workflow)
store.resultMedia = { url: 'blob:first-run', kind: 'image' }
store.showNudge()
store.end()
expect(store.resultMedia).toEqual({ url: 'blob:first-run', kind: 'image' })
})
it('defers the nudge while the upgrade modal is open, then surfaces it on close', () => {
isDialogOpen.mockReturnValue(true)
store.showNudge()
// Held back so it never overlaps the paywall; the arm flag drives the retry.
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(true)
// The end of the tour must not lose the deferred nudge.
store.end()
expect(store.nudgeArmed).toBe(true)
isDialogOpen.mockReturnValue(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
expect(store.nudgeArmed).toBe(false)
})
it('dismissNudge() hides it and blocks any later re-trigger this session', () => {
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
store.dismissNudge()
expect(store.shouldShowNudge).toBe(false)
store.showNudge()
expect(store.shouldShowNudge).toBe(false)
})
it('start() resets the nudge lifecycle for a fresh tour', () => {
resolveTourRoles.mockReturnValue(t2iRoles)
store.showNudge()
store.dismissNudge()
store.resultMedia = { url: 'blob:prior-run', kind: 'image' }
store.start(workflow)
expect(store.shouldShowNudge).toBe(false)
expect(store.nudgeArmed).toBe(false)
// The prior run's media is cleared with the rest of the nudge lifecycle.
expect(store.resultMedia).toBeNull()
// Dismissal from the prior tour no longer blocks the new one.
store.showNudge()
expect(store.shouldShowNudge).toBe(true)
})
})

View File

@@ -0,0 +1,213 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { UPGRADE_DIALOG_KEYS } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useDialogStore } from '@/stores/dialogStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { resolveNode } from '@/utils/litegraphUtil'
import { resolveTourRoles } from './roleResolution'
import { restoreView } from './subgraphNavigation'
import { sequenceBuilder } from './tourSequence'
import type { MediaKind, ResolvedRoles, TourStep } from './tourSequence'
export type TourPhase = 'idle' | 'active'
export type TourEndReason = 'done' | 'skip' | 'error'
export interface ResultMedia {
url: string
kind: MediaKind
}
/** How long to wait for the sink's output URL after a run before giving up. */
const RESULT_MEDIA_TIMEOUT_MS = 8000
export function isUpgradeModalOpen(): boolean {
const dialogStore = useDialogStore()
return UPGRADE_DIALOG_KEYS.some((key) => dialogStore.isDialogOpen(key))
}
/**
* The graph nodes a step points at: its own target, plus the prompt's collapsed host
* (the subgraph is never entered). Shared by the spotlight and reveal derivations so
* they can't drift.
*/
function stepTargets(step: TourStep): NodeId[] {
const ids: NodeId[] = []
if (step.nodeId !== null) ids.push(step.nodeId)
if (step.prompt) ids.push(step.prompt.subgraphNodeId)
return ids
}
/** Tour state. All litegraph reads happen through the canvas adapter, never here. */
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
const phase = ref<TourPhase>('idle')
const stepIndex = ref(0)
const steps = ref<TourStep[]>([])
const resolvedRoles = ref<ResolvedRoles | null>(null)
const revealedNodeIds = ref<Set<NodeId>>(new Set())
const resultMedia = ref<ResultMedia | null>(null)
/**
* Set on any run outcome. Drives "Generating…", which `resultMedia` alone cannot:
* a capture that times out must not read as generating forever.
*/
const runFinished = ref(false)
/**
* Bumped once per `start()`, which passes through idle within one tick — so a
* watcher on `phase` cannot tell a restart from a step change, but can tell on this.
*/
const tourRunId = ref(0)
/** Drives the bottom-right nudge; outlives the tour so it can show after it ends. */
const shouldShowNudge = ref(false)
/** Set when `showNudge` defers because the upgrade modal is open; the modal-close watch drains it. */
const nudgeArmed = ref(false)
/** "Not now" latches this so no later trigger can resurface the nudge this session. */
const nudgeDismissed = ref(false)
const currentStep = computed<TourStep | null>(
() => steps.value[stepIndex.value] ?? null
)
const totalSteps = computed(() => steps.value.length)
/** Nodes the current step targets, spotlit brightly while prior reveals stay dim. */
const spotlitNodeIds = computed<Set<NodeId>>(() => {
const step = currentStep.value
return step ? new Set(stepTargets(step)) : new Set()
})
/**
* Reveal every node targeted by steps up to and including `stepIndex` so the
* graph builds up — except the final Result step, which collapses back to just
* the sink so the generated output is the sole focus.
*/
function syncRevealed() {
const step = currentStep.value
if (step?.kind === 'result' && step.nodeId !== null) {
revealedNodeIds.value = new Set([step.nodeId])
return
}
const revealed = new Set<NodeId>()
for (const prior of steps.value.slice(0, stepIndex.value + 1)) {
for (const id of stepTargets(prior)) revealed.add(id)
}
revealedNodeIds.value = revealed
}
function start(workflow: ComfyWorkflowJSON, templateId?: string) {
reset()
resetNudge()
const roles = resolveTourRoles(workflow, templateId)
resolvedRoles.value = roles
steps.value = sequenceBuilder(roles)
tourRunId.value += 1
phase.value = 'active'
syncRevealed()
}
function advance() {
if (stepIndex.value >= steps.value.length - 1) return
stepIndex.value += 1
syncRevealed()
}
function back() {
if (stepIndex.value <= 0) return
stepIndex.value -= 1
syncRevealed()
}
/**
* Record the sink's output as the Result step's media. The URL lands just after the
* run finishes (the cloud queue refresh fetches it), so wait rather than drop it.
* Kind comes from the resolved sink, not the MIME, so restored results still render.
*/
async function captureResultMedia() {
if (phase.value !== 'active' || resultMedia.value) return
const sink = resolvedRoles.value?.sink
if (!sink) return
const sinkUrl = () => {
const node = resolveNode(sink.nodeId)
return node
? (useNodeOutputStore().getNodeImageUrls(node)?.[0] ?? '')
: ''
}
const url = await until(sinkUrl).toMatch((value) => value.length > 0, {
timeout: RESULT_MEDIA_TIMEOUT_MS,
throwOnTimeout: false
})
if (!url || phase.value !== 'active') return
resultMedia.value = { url, kind: resolvedRoles.value?.mediaKind ?? 'image' }
}
/**
* Surface the bottom-right nudge once the tour ends — on every outcome. If the
* upgrade modal is open, defer instead (arm it) so the two never overlap; the
* modal-close watch re-runs this once the modal clears. Dismissal wins over both.
*/
function showNudge() {
if (nudgeDismissed.value) return
if (isUpgradeModalOpen()) {
nudgeArmed.value = true
return
}
nudgeArmed.value = false
shouldShowNudge.value = true
}
/** "Not now": hide the nudge and block any later trigger this session. */
function dismissNudge() {
shouldShowNudge.value = false
nudgeDismissed.value = true
}
/** Nudge state outlives the tour, so only a fresh tour clears it. */
function resetNudge() {
shouldShowNudge.value = false
nudgeArmed.value = false
nudgeDismissed.value = false
resultMedia.value = null
runFinished.value = false
}
function reset() {
phase.value = 'idle'
stepIndex.value = 0
steps.value = []
resolvedRoles.value = null
revealedNodeIds.value = new Set()
}
function end() {
restoreView()
reset()
}
return {
phase,
stepIndex,
steps,
currentStep,
totalSteps,
resolvedRoles,
revealedNodeIds,
spotlitNodeIds,
resultMedia,
runFinished,
tourRunId,
shouldShowNudge,
nudgeArmed,
start,
advance,
back,
captureResultMedia,
showNudge,
dismissNudge,
end
}
})

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const resolveRoles = vi.hoisted(() => vi.fn())
vi.mock('./roleResolver', () => ({ resolveRoles }))
interface FakeNodeDef {
output_node: boolean
outputs: { type: string }[]
}
const nodeDefsByName = vi.hoisted(() => ({}) as Record<string, FakeNodeDef>)
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ nodeDefsByName })
}))
import { resolveTourRoles } from './roleResolution'
type Lookup = (
type: string
) => { isOutputNode: boolean; producesVideo: boolean } | null
const workflow = {} as ComfyWorkflowJSON
function capturedLookup(): Lookup {
return resolveRoles.mock.calls[0][2] as Lookup
}
describe('resolveTourRoles', () => {
beforeEach(() => {
resolveRoles.mockReset()
for (const key of Object.keys(nodeDefsByName)) delete nodeDefsByName[key]
})
it('forwards the workflow and templateId to resolveRoles', () => {
resolveTourRoles(workflow, 'image_z_image_turbo')
expect(resolveRoles).toHaveBeenCalledWith(
workflow,
'image_z_image_turbo',
expect.any(Function)
)
})
it('injects a lookup that rejects prototype keys and maps registry defs', () => {
// The lookup indexes `nodeDefsByName` by an attacker-craftable node type, so
// it must reject prototype members and only report real registry defs.
nodeDefsByName.SaveVideo = {
output_node: true,
outputs: [{ type: 'VIDEO' }]
}
nodeDefsByName.SaveImage = {
output_node: true,
outputs: [{ type: 'IMAGE' }]
}
resolveTourRoles(workflow)
const lookup = capturedLookup()
expect(lookup('toString')).toBeNull()
expect(lookup('constructor')).toBeNull()
expect(lookup('unregistered')).toBeNull()
expect(lookup('SaveVideo')).toEqual({
isOutputNode: true,
producesVideo: true
})
expect(lookup('SaveImage')).toEqual({
isOutputNode: true,
producesVideo: false
})
})
})

View File

@@ -0,0 +1,35 @@
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resolveRoles } from './roleResolver'
import type { NodeDefLookup } from './roleResolver'
import type { ResolvedRoles } from './tourSequence'
/** Output types that mark a registry sink as producing video rather than an image. */
const VIDEO_OUTPUT_TYPES = new Set(['VIDEO', 'VHS_VIDEOINFO'])
/** Reads the node registry so the resolver can widen sink detection to custom output nodes. */
const nodeDefLookup: NodeDefLookup = (type) => {
const defs = useNodeDefStore().nodeDefsByName
if (!Object.hasOwn(defs, type)) return null
const def = defs[type]
return {
isOutputNode: def.output_node,
producesVideo: def.outputs.some((output) =>
VIDEO_OUTPUT_TYPES.has(output.type)
)
}
}
/**
* Resolve tour roles with the live node registry injected, so registry-only
* custom sinks are detected. The single entry point the store and the readiness
* gate share, so both see the same roles (the gate reads the live graph the
* spotlight reads — they must not diverge on which sink exists).
*/
export function resolveTourRoles(
workflow: ComfyWorkflowJSON,
templateId?: string
): ResolvedRoles {
return resolveRoles(workflow, templateId, nodeDefLookup)
}

View File

@@ -0,0 +1,564 @@
import { describe, expect, it } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import { loadTemplateWorkflow } from './__fixtures__/loadTemplateWorkflow'
import {
fromWorkflowJson,
resolveRoles,
templateOverrides
} from './roleResolver'
import type { CuratedTemplateId } from './roleResolver'
interface CuratedExpectation {
sourceId: number | null
promptInnerId: number
promptWidget: string
engineId: number
sinkId: number
mediaKind: 'image' | 'video'
}
const CURATED: Record<CuratedTemplateId, CuratedExpectation> = {
image_krea2_turbo_t2i: {
sourceId: null,
promptInnerId: 19,
promptWidget: 'value',
engineId: 3,
sinkId: 29,
mediaKind: 'image'
},
image_z_image_turbo: {
sourceId: null,
promptInnerId: 27,
promptWidget: 'text',
engineId: 3,
sinkId: 9,
mediaKind: 'image'
},
video_ltx2_3_i2v: {
sourceId: 269,
promptInnerId: 319,
promptWidget: 'value',
engineId: 283,
sinkId: 75,
mediaKind: 'video'
},
video_wan2_2_14B_i2v: {
sourceId: 97,
promptInnerId: 93,
promptWidget: 'text',
engineId: 86,
sinkId: 108,
mediaKind: 'video'
},
flux_kontext_dev_basic: {
sourceId: 190,
promptInnerId: 6,
promptWidget: 'text',
engineId: 31,
sinkId: 136,
mediaKind: 'image'
}
}
const curatedIds = Object.keys(CURATED) as CuratedTemplateId[]
describe('resolveRoles — curated templates, heuristics only (no override)', () => {
it.for(curatedIds)('resolves %s from its real JSON', (id) => {
const expected = CURATED[id]
const roles = resolveRoles(loadTemplateWorkflow(id))
if (expected.sourceId === null) {
expect(roles.source).toBeNull()
} else {
expect(roles.source?.nodeId).toBe(toNodeId(expected.sourceId))
}
expect(roles.prompt?.innerNodeId).toBe(toNodeId(expected.promptInnerId))
expect(roles.prompt?.widgetName).toBe(expected.promptWidget)
expect(roles.engine?.nodeId).toBe(toNodeId(expected.engineId))
expect(roles.sink?.nodeId).toBe(toNodeId(expected.sinkId))
expect(roles.mediaKind).toBe(expected.mediaKind)
})
it('rejects the internal-fed decoy CLIPTextEncode (krea 6, ltx 303)', () => {
expect(
resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i')).prompt
?.innerNodeId
).not.toBe(toNodeId(6))
expect(
resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v')).prompt?.innerNodeId
).not.toBe(toNodeId(303))
})
it('picks the boundary-fed user prompt over a sibling system prompt (krea)', () => {
const roles = resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i'))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(19))
})
it('picks the positive CLIPTextEncode over the negative one (wan)', () => {
const roles = resolveRoles(loadTemplateWorkflow('video_wan2_2_14B_i2v'))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(93))
})
it('picks one source when several LoadImage exist (flux)', () => {
const roles = resolveRoles(loadTemplateWorkflow('flux_kontext_dev_basic'))
expect(roles.source?.nodeId).toBe(toNodeId(190))
})
it('exposes the subgraph node id and prompt port for fallback', () => {
const zImage = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
expect(zImage.prompt?.subgraphNodeId).toBe(toNodeId(57))
expect(zImage.prompt?.portFallback).toBe('text')
const ltx = resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v'))
expect(ltx.prompt?.portFallback).toBe('value')
})
})
describe('templateOverrides pin the heuristic result', () => {
it('matches the heuristic result for every curated id', () => {
for (const id of curatedIds) {
const roles = resolveRoles(loadTemplateWorkflow(id))
const pin = templateOverrides[id]
expect(pin.promptNodeId).toBe(roles.prompt?.innerNodeId)
expect(pin.engineNodeId).toBe(roles.engine?.nodeId)
expect(pin.sinkNodeId).toBe(roles.sink?.nodeId)
expect(pin.mediaKind).toBe(roles.mediaKind)
if (roles.source) expect(pin.sourceNodeId).toBe(roles.source.nodeId)
}
})
it('override sink, engine, and media win over the heuristics on the graph', () => {
const pin = templateOverrides.image_z_image_turbo
const roles = resolveRoles(
workflow([
node(900, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
]),
'image_z_image_turbo'
)
expect(roles.sink?.nodeId).toBe(pin.sinkNodeId)
expect(roles.engine?.nodeId).toBe(pin.engineNodeId)
expect(roles.mediaKind).toBe(pin.mediaKind)
expect(roles.sink?.nodeId).not.toBe(toNodeId(900))
expect(roles.mediaKind).not.toBe('video')
})
it('degrades the prompt to null when the pinned inner node is absent', () => {
const roles = resolveRoles(
workflow([
node(900, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 1 }]
})
]),
'image_z_image_turbo'
)
expect(roles.prompt).toBeNull()
})
it('spotlights the subgraph host, not the pinned inner node, for a nested prompt', () => {
const roles = resolveRoles(
loadTemplateWorkflow('flux_kontext_dev_basic'),
'flux_kontext_dev_basic'
)
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
expect(roles.prompt?.subgraphNodeId).toBe(toNodeId(192))
})
it('ignores an unknown template id and falls back to heuristics', () => {
const withUnknown = resolveRoles(
loadTemplateWorkflow('image_z_image_turbo'),
'not_a_real_template'
)
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
expect(withUnknown).toEqual(heuristic)
})
})
function workflow(nodes: unknown[], links: unknown[] = []): ComfyWorkflowJSON {
return {
version: 0.4,
nodes,
links,
extra: {}
} as unknown as ComfyWorkflowJSON
}
function node(
id: number,
type: string,
extra: Record<string, unknown> = {}
): Record<string, unknown> {
return {
id,
type,
pos: [0, 0],
size: [1, 1],
flags: {},
order: id,
mode: 0,
properties: {},
...extra
}
}
function cte(id: number, conditioningLink: number, text: string) {
return node(id, 'CLIPTextEncode', {
inputs: [{ name: 'text', type: 'STRING' }],
outputs: [
{ name: 'CONDITIONING', type: 'CONDITIONING', links: [conditioningLink] }
],
widgets_values: [text]
})
}
describe('resolveRoles — arbitrary (non-curated) graphs', () => {
it('resolves a top-level T2I, choosing the positive prompt by conditioning', () => {
const roles = resolveRoles(
workflow(
[
cte(6, 4, 'a red fox'),
cte(7, 6, 'blurry'),
node(3, 'KSampler', {
inputs: [
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 }
]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[
[4, 6, 0, 3, 1, 'CONDITIONING'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[8, 3, 0, 9, 0, 'IMAGE']
]
)
)
expect(roles.source).toBeNull()
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
expect(roles.prompt?.widgetName).toBe('text')
expect(roles.engine?.nodeId).toBe(toNodeId(3))
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('image')
})
it('resolves a top-level I2V with a video sink', () => {
const roles = resolveRoles(
workflow(
[
node(1, 'LoadImage', {
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [10] }]
}),
cte(2, 11, 'dancing'),
node(3, 'KSamplerAdvanced', {
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 11 }]
}),
node(4, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 12 }]
})
],
[
[10, 1, 0, 3, 3, 'IMAGE'],
[11, 2, 0, 3, 1, 'CONDITIONING'],
[12, 3, 0, 4, 0, 'VIDEO']
]
)
)
expect(roles.source?.nodeId).toBe(toNodeId(1))
expect(roles.prompt?.innerNodeId).toBe(toNodeId(2))
expect(roles.engine?.nodeId).toBe(toNodeId(3))
expect(roles.sink?.nodeId).toBe(toNodeId(4))
expect(roles.mediaKind).toBe('video')
})
it('prefers a Save sink over a Preview sink', () => {
const roles = resolveRoles(
workflow(
[
cte(6, 4, 'x'),
node(3, 'KSampler', {
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 4 }]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
}),
node(19, 'PreviewImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 20 }]
})
],
[
[4, 6, 0, 3, 1, 'CONDITIONING'],
[8, 3, 0, 9, 0, 'IMAGE'],
[20, 3, 0, 19, 0, 'IMAGE']
]
)
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
})
})
describe('fromWorkflowJson normalizes the serialized shape', () => {
it('reads tuple links at the top level', () => {
const graph = fromWorkflowJson(
workflow(
[
node(1, 'KSampler', {
outputs: [{ name: 'LATENT', type: 'LATENT', links: [5] }]
}),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
})
],
[[5, 1, 0, 2, 0, 'IMAGE']]
)
)
expect(graph.nodes[0].hasOutgoingLinks).toBe(true)
expect(graph.nodes[1].inputs[0].origin).toEqual({
kind: 'node',
nodeId: toNodeId(1),
slot: 0
})
})
it('reads object links and the boundary origin inside subgraphs', () => {
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
const subgraph = graph.subgraphs[0]
const clip = subgraph.nodes.find((n) => n.id === toNodeId(27))
const textInput = clip?.inputs.find((i) => i.name === 'text')
expect(textInput?.origin).toEqual({ kind: 'boundary', slot: 0 })
})
it('flags the hosting subgraph node', () => {
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
const host = graph.nodes.find((n) => n.subgraphId !== null)
expect(host?.subgraphId).toBe(graph.subgraphs[0].id)
})
})
describe('resolveRoles — graceful degradation for any input', () => {
it('returns all-null roles for a graph with no sink or prompt', () => {
const roles = resolveRoles(
workflow([node(1, 'MarkdownNote'), node(2, 'Reroute')])
)
expect(roles.source).toBeNull()
expect(roles.prompt).toBeNull()
expect(roles.engine).toBeNull()
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('does not throw on an empty graph', () => {
expect(() => resolveRoles(workflow([]))).not.toThrow()
})
it('resolves the sink even when the prompt is missing', () => {
const roles = resolveRoles(
workflow([
node(9, 'SaveVideo', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
])
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('video')
expect(roles.prompt).toBeNull()
})
it('leaves the prompt null when a subgraph port leads to no editable node', () => {
const subgraphId = '11111111-1111-1111-1111-111111111111'
const graph = {
...workflow([
node(1, subgraphId, { inputs: [] }),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
})
]),
definitions: {
subgraphs: [
{
id: subgraphId,
inputs: [{ name: 'text', type: 'STRING', label: 'prompt' }],
nodes: [node(10, 'VAEDecode', { inputs: [] })],
links: []
}
]
}
} as unknown as ComfyWorkflowJSON
const roles = resolveRoles(graph)
expect(roles.prompt).toBeNull()
expect(roles.sink?.nodeId).toBe(toNodeId(2))
})
it('skips malformed link entries without throwing', () => {
const graph = {
...workflow(
[
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[null, 'garbage', [8, 3, 0, 9, 0, 'IMAGE']]
)
} as unknown as ComfyWorkflowJSON
expect(() => resolveRoles(graph)).not.toThrow()
expect(resolveRoles(graph).sink?.nodeId).toBe(toNodeId(9))
})
it('prefers a terminal Save sink over a Save that feeds onward', () => {
const roles = resolveRoles(
workflow(
[
node(1, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 4 }],
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [5] }]
}),
node(2, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 6 }]
})
],
[
[5, 1, 0, 2, 0, 'IMAGE'],
[6, 1, 0, 2, 0, 'IMAGE']
]
)
)
expect(roles.sink?.nodeId).toBe(toNodeId(2))
})
})
describe('resolveRoles — hostile node types do not match prototype members', () => {
it('does not treat a node typed "constructor" as a sink', () => {
const roles = resolveRoles(
workflow([node(1, 'constructor', { inputs: [] })])
)
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('ignores a template id that names a prototype member', () => {
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
for (const id of ['constructor', 'toString', 'hasOwnProperty']) {
expect(
resolveRoles(loadTemplateWorkflow('image_z_image_turbo'), id)
).toEqual(heuristic)
}
})
})
describe('resolveRoles — registry-backed sink fallback', () => {
// A custom save node outside the hardcoded SINK_MEDIA list, only recognizable
// as a sink through the injected registry lookup.
const customSinkGraph = workflow([
node(9, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
})
])
const lookup = (type: string) =>
type === 'MyCustomVideoSave'
? { isOutputNode: true, producesVideo: true }
: null
it('resolves a custom output node as the sink when the type list misses', () => {
const roles = resolveRoles(customSinkGraph, undefined, lookup)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('video')
})
it('leaves the sink null for the same graph without a registry lookup', () => {
const roles = resolveRoles(customSinkGraph)
expect(roles.sink).toBeNull()
expect(roles.mediaKind).toBe('image')
})
it('does not use the fallback when a known sink type already matches', () => {
// A registry that would (wrongly) label the KSampler an output node must not
// steal the sink from the real SaveImage.
const greedyLookup = () => ({ isOutputNode: true, producesVideo: false })
const roles = resolveRoles(
workflow(
[
node(3, 'KSampler', {
outputs: [{ name: 'LATENT', type: 'LATENT', links: [8] }]
}),
node(9, 'SaveImage', {
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
})
],
[[8, 3, 0, 9, 0, 'IMAGE']]
),
undefined,
greedyLookup
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
})
it('ignores a registry output node that still feeds downstream', () => {
// Only terminal output nodes are sinks; one with outgoing links is not.
const roles = resolveRoles(
workflow(
[
node(9, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }],
outputs: [{ name: 'VIDEO', type: 'VIDEO', links: [2] }]
}),
node(10, 'MyCustomVideoSave', {
inputs: [{ name: 'video', type: 'VIDEO', link: 2 }]
})
],
[[2, 9, 0, 10, 0, 'VIDEO']]
),
undefined,
lookup
)
expect(roles.sink?.nodeId).toBe(toNodeId(10))
})
it('does not treat a non-output registry node as a sink', () => {
const roles = resolveRoles(
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
undefined,
(type: string) =>
type === 'MyCustomImageSave'
? { isOutputNode: false, producesVideo: false }
: null
)
// isOutputNode:false → not a sink at all, so it stays null.
expect(roles.sink).toBeNull()
})
it('infers image media for an accepted output node with no video output', () => {
const roles = resolveRoles(
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
undefined,
(type: string) =>
type === 'MyCustomImageSave'
? { isOutputNode: true, producesVideo: false }
: null
)
expect(roles.sink?.nodeId).toBe(toNodeId(9))
expect(roles.mediaKind).toBe('image')
})
})

View File

@@ -0,0 +1,540 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import type {
MediaKind,
NodeRole,
PromptRole,
ResolvedRoles
} from './tourSequence'
/**
* A minimal, faithful view of the parts of a workflow the resolver reads,
* projected from a serialized `ComfyWorkflowJSON` (see {@link fromWorkflowJson}).
* Keeping the resolver on this view makes it pure and testable against real
* template JSON without instantiating a live graph.
*/
export interface WorkflowGraph {
nodes: WorkflowGraphNode[]
subgraphs: WorkflowSubgraph[]
}
interface WorkflowGraphNode {
id: NodeId
type: string
inputs: WorkflowInput[]
/** True when this node hosts a subgraph, keyed to a {@link WorkflowSubgraph}. */
subgraphId: string | null
hasOutgoingLinks: boolean
}
interface WorkflowInput {
name: string
/** Origin of the link feeding this input, or null when driven by its widget. */
origin: WorkflowLinkOrigin | null
}
interface WorkflowSubgraph {
id: string
nodes: WorkflowGraphNode[]
exposedInputs: ExposedInput[]
}
interface ExposedInput {
name: string
type: string
label: string | null
slot: number
}
/** Where a link originates: an inner node, or the subgraph's input boundary. */
type WorkflowLinkOrigin =
| { kind: 'node'; nodeId: NodeId; slot: number }
| { kind: 'boundary'; slot: number }
/**
* Sink node types that terminate a generation, and the media each yields. A Map
* (not an object) so an attacker-crafted `node.type` like `"constructor"` can't
* match a prototype member and smuggle a non-`MediaKind` value through.
*/
const SINK_MEDIA = new Map<string, MediaKind>([
['SaveImage', 'image'],
['PreviewImage', 'image'],
['SaveAnimatedWEBP', 'image'],
['SaveVideo', 'video'],
['VHS_VideoCombine', 'video'],
['SaveWEBM', 'video']
])
const IMAGE_SOURCE_TYPES = new Set([
'LoadImage',
'LoadImageMask',
'LoadImageOutput'
])
const SAMPLER_TYPES = new Set([
'KSampler',
'KSamplerAdvanced',
'SamplerCustom',
'SamplerCustomAdvanced'
])
/**
* A read of the node registry, injected so the resolver stays pure and testable
* without a live `nodeDefStore`. Returns null for unknown types.
*/
export type NodeDefLookup = (
type: string
) => { isOutputNode: boolean; producesVideo: boolean } | null
/** Exposed-port names/labels that mark the editable prompt boundary. */
const PROMPT_PORT_NAMES = new Set(['prompt', 'text', 'value'])
const CLIP_TEXT_ENCODE = 'CLIPTextEncode'
const PROMPT_PRIMITIVE = 'PrimitiveStringMultiline'
/** Pinned node ids for a curated template — a confidence layer over heuristics. */
interface TemplateOverride {
sourceNodeId?: NodeId
promptNodeId: NodeId
engineNodeId: NodeId
sinkNodeId: NodeId
mediaKind: MediaKind
}
export type CuratedTemplateId =
| 'image_krea2_turbo_t2i'
| 'image_z_image_turbo'
| 'video_ltx2_3_i2v'
| 'video_wan2_2_14B_i2v'
| 'flux_kontext_dev_basic'
export const templateOverrides: Record<CuratedTemplateId, TemplateOverride> = {
image_krea2_turbo_t2i: {
promptNodeId: toNodeId(19),
engineNodeId: toNodeId(3),
sinkNodeId: toNodeId(29),
mediaKind: 'image'
},
image_z_image_turbo: {
promptNodeId: toNodeId(27),
engineNodeId: toNodeId(3),
sinkNodeId: toNodeId(9),
mediaKind: 'image'
},
video_ltx2_3_i2v: {
sourceNodeId: toNodeId(269),
promptNodeId: toNodeId(319),
engineNodeId: toNodeId(283),
sinkNodeId: toNodeId(75),
mediaKind: 'video'
},
video_wan2_2_14B_i2v: {
sourceNodeId: toNodeId(97),
promptNodeId: toNodeId(93),
engineNodeId: toNodeId(86),
sinkNodeId: toNodeId(108),
mediaKind: 'video'
},
flux_kontext_dev_basic: {
sourceNodeId: toNodeId(190),
promptNodeId: toNodeId(6),
engineNodeId: toNodeId(31),
sinkNodeId: toNodeId(136),
mediaKind: 'image'
}
}
interface SinkMatch {
node: WorkflowGraphNode
mediaKind: MediaKind
}
function findSink(
graph: WorkflowGraph,
nodeDefLookup: NodeDefLookup
): SinkMatch | null {
const candidates = graph.nodes.flatMap((node) => {
const mediaKind = SINK_MEDIA.get(node.type)
return mediaKind ? [{ node, mediaKind }] : []
})
if (candidates.length === 0) return findRegistrySink(graph, nodeDefLookup)
const isSave = (match: SinkMatch) => match.node.type.startsWith('Save')
const [best] = candidates.sort((a, b) => {
if (isSave(a) !== isSave(b)) return isSave(a) ? -1 : 1
if (a.node.hasOutgoingLinks !== b.node.hasOutgoingLinks) {
return a.node.hasOutgoingLinks ? 1 : -1
}
return 0
})
return best
}
/**
* Fallback when no known save/preview type matches: a terminal node the registry
* marks as an output node. Widens sink detection to custom save nodes on
* arbitrary share/`?template=` workflows, where the hardcoded type list misses.
*/
function findRegistrySink(
graph: WorkflowGraph,
nodeDefLookup: NodeDefLookup
): SinkMatch | null {
const node = graph.nodes.find((n) => {
if (n.hasOutgoingLinks) return false
return nodeDefLookup(n.type)?.isOutputNode === true
})
if (!node) return null
const producesVideo = nodeDefLookup(node.type)?.producesVideo === true
return { node, mediaKind: producesVideo ? 'video' : 'image' }
}
function findSource(graph: WorkflowGraph): WorkflowGraphNode | null {
return graph.nodes.find((n) => IMAGE_SOURCE_TYPES.has(n.type)) ?? null
}
function firstSampler(nodes: WorkflowGraphNode[]): WorkflowGraphNode | null {
return nodes.find((n) => SAMPLER_TYPES.has(n.type)) ?? null
}
function subgraphHost(
graph: WorkflowGraph
): { node: WorkflowGraphNode; subgraph: WorkflowSubgraph } | null {
for (const node of graph.nodes) {
if (!node.subgraphId) continue
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
if (subgraph) return { node, subgraph }
}
return null
}
function promptPort(subgraph: WorkflowSubgraph): ExposedInput | null {
const strings = subgraph.exposedInputs.filter((p) => p.type === 'STRING')
const named = strings.find(
(p) => PROMPT_PORT_NAMES.has(p.label ?? '') || PROMPT_PORT_NAMES.has(p.name)
)
return named ?? strings[0] ?? null
}
/** Inner node whose named widget-input is fed from the given boundary port. */
function boundaryFedNode(
subgraph: WorkflowSubgraph,
portSlot: number,
widgetName: string
): WorkflowGraphNode | null {
return (
subgraph.nodes.find((node) =>
node.inputs.some(
(input) =>
input.name === widgetName &&
input.origin?.kind === 'boundary' &&
input.origin.slot === portSlot
)
) ?? null
)
}
function resolveSubgraphPrompt(
subgraphNodeId: NodeId,
subgraph: WorkflowSubgraph
): PromptRole | null {
const port = promptPort(subgraph)
if (!port) return null
const primitive = boundaryFedNode(subgraph, port.slot, 'value')
if (primitive?.type === PROMPT_PRIMITIVE) {
return {
subgraphNodeId,
innerNodeId: primitive.id,
widgetName: 'value',
portFallback: port.name
}
}
const clip = boundaryFedNode(subgraph, port.slot, 'text')
if (clip?.type === CLIP_TEXT_ENCODE) {
return {
subgraphNodeId,
innerNodeId: clip.id,
widgetName: 'text',
portFallback: port.name
}
}
return null
}
/**
* The CLIPTextEncode feeding the first sampler's positive input, else the first
* CLIPTextEncode anywhere (best-effort for graphs with no wired sampler).
*/
function positivePromptNode(graph: WorkflowGraph): WorkflowGraphNode | null {
const sampler = firstSampler(graph.nodes)
const positive = sampler?.inputs.find((i) => i.name === 'positive')
if (positive?.origin?.kind === 'node') {
const origin = positive.origin
const feeder = graph.nodes.find((n) => n.id === origin.nodeId)
if (feeder?.type === CLIP_TEXT_ENCODE) return feeder
}
return graph.nodes.find((n) => n.type === CLIP_TEXT_ENCODE) ?? null
}
function resolveTopLevelPrompt(graph: WorkflowGraph): PromptRole | null {
const node = positivePromptNode(graph)
if (!node) return null
return {
subgraphNodeId: node.id,
innerNodeId: node.id,
widgetName: 'text',
portFallback: null
}
}
function toNodeRole(nodeId: NodeId): NodeRole {
return { nodeId }
}
/**
* The pinned override for a curated template, if one exists. `Object.hasOwn`
* guards against a `?template=` id like `"constructor"` matching a prototype
* member and yielding a non-override value.
*/
function overrideFor(templateId: string | undefined): TemplateOverride | null {
if (!templateId || !Object.hasOwn(templateOverrides, templateId)) return null
return templateOverrides[templateId as CuratedTemplateId]
}
/**
* The root-graph node to spotlight for a prompt whose editable widget lives on
* `innerNodeId`: the node itself when it sits on the root graph, else the
* collapsed host of the subgraph that contains it. Null when the id belongs to
* no known node — the tour never enters a subgraph, so an inner id can't be a
* spotlight target.
*/
function promptHostId(
graph: WorkflowGraph,
innerNodeId: NodeId
): NodeId | null {
if (graph.nodes.some((n) => n.id === innerNodeId)) return innerNodeId
for (const node of graph.nodes) {
if (!node.subgraphId) continue
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
if (subgraph?.nodes.some((n) => n.id === innerNodeId)) return node.id
}
return null
}
/**
* Build the prompt role from the override's pinned inner node. `subgraphNodeId`
* (the spotlight target) is resolved to a root-graph node so it never points at
* a raw inner node; widget/port detail comes from the heuristic only when it
* resolved the same inner node. Prompt degrades to null if the inner id is
* unreachable.
*/
function overridePrompt(
graph: WorkflowGraph,
override: TemplateOverride,
heuristicPrompt: PromptRole | null
): PromptRole | null {
const subgraphNodeId = promptHostId(graph, override.promptNodeId)
if (subgraphNodeId === null) return null
const sameInner = heuristicPrompt?.innerNodeId === override.promptNodeId
return {
subgraphNodeId,
innerNodeId: override.promptNodeId,
widgetName: sameInner ? heuristicPrompt.widgetName : 'text',
portFallback: sameInner ? heuristicPrompt.portFallback : null
}
}
function applyOverride(
graph: WorkflowGraph,
override: TemplateOverride,
heuristicPrompt: PromptRole | null
): ResolvedRoles {
return {
source: override.sourceNodeId ? toNodeRole(override.sourceNodeId) : null,
prompt: overridePrompt(graph, override, heuristicPrompt),
engine: toNodeRole(override.engineNodeId),
sink: toNodeRole(override.sinkNodeId),
mediaKind: override.mediaKind
}
}
/**
* Inspects a loaded workflow and returns the roles the tour targets. Works on
* any graph — curated templates, arbitrary share/`?template=` workflows, and
* custom user templates alike — via structural heuristics. Never throws; any
* role that cannot be resolved is left null so the sequence builder degrades
* gracefully. When `templateId` names a curated template, its pinned ids take
* precedence over the heuristics.
*/
export function resolveRoles(
workflow: ComfyWorkflowJSON,
templateId?: string,
nodeDefLookup: NodeDefLookup = () => null
): ResolvedRoles {
const graph = fromWorkflowJson(workflow)
const host = subgraphHost(graph)
const prompt = host
? resolveSubgraphPrompt(host.node.id, host.subgraph)
: resolveTopLevelPrompt(graph)
const override = overrideFor(templateId)
if (override) return applyOverride(graph, override, prompt)
const sink = findSink(graph, nodeDefLookup)
const source = findSource(graph)
const engine = firstSampler(host ? host.subgraph.nodes : graph.nodes)
return {
source: source ? toNodeRole(source.id) : null,
prompt,
engine: engine ? toNodeRole(engine.id) : null,
sink: sink ? toNodeRole(sink.node.id) : null,
mediaKind: sink?.mediaKind ?? 'image'
}
}
// --- Projection from serialized ComfyWorkflowJSON onto the resolver view. ---
interface RawNode {
id: number | string
type?: string
inputs?: RawInput[]
outputs?: { links?: number[] | null }[]
}
interface RawInput {
name?: string
link?: number | null
}
interface RawExposedInput {
name?: string
type?: string
label?: string | null
}
interface RawSubgraph {
id: string
nodes?: RawNode[]
links?: unknown
inputs?: RawExposedInput[]
}
/**
* The parts of a serialized workflow this resolver reads. `ComfyWorkflowJSON`'s
* own type does not statically expose `definitions.subgraphs[].nodes/links`
* (its recursive subgraph type omits them), so the projection asserts this
* narrower view once at {@link fromWorkflowJson} and re-parses links loosely.
*/
interface RawWorkflow {
nodes?: RawNode[]
links?: unknown
definitions?: { subgraphs?: RawSubgraph[] }
}
interface RawLink {
id: number
originId: NodeId
originSlot: number
targetId: NodeId
targetSlot: number
}
const SUBGRAPH_INPUT_ORIGIN = '-10'
function readLink(raw: unknown): RawLink | null {
if (Array.isArray(raw) && raw.length >= 6) {
return {
id: Number(raw[0]),
originId: toNodeId(String(raw[1])),
originSlot: Number(raw[2]),
targetId: toNodeId(String(raw[3])),
targetSlot: Number(raw[4])
}
}
if (raw && typeof raw === 'object' && 'origin_id' in raw) {
const linkObject = raw as Record<string, unknown>
return {
id: Number(linkObject.id),
originId: toNodeId(String(linkObject.origin_id)),
originSlot: Number(linkObject.origin_slot),
targetId: toNodeId(String(linkObject.target_id)),
targetSlot: Number(linkObject.target_slot)
}
}
return null
}
function readLinks(raw: unknown): RawLink[] {
if (!Array.isArray(raw)) return []
return raw.map(readLink).filter((l): l is RawLink => l !== null)
}
function originFor(
input: RawInput,
links: RawLink[]
): WorkflowLinkOrigin | null {
if (typeof input.link !== 'number') return null
const link = links.find((l) => l.id === input.link)
if (!link) return null
if (String(link.originId) === SUBGRAPH_INPUT_ORIGIN) {
return { kind: 'boundary', slot: link.originSlot }
}
return { kind: 'node', nodeId: link.originId, slot: link.originSlot }
}
function readNode(
raw: RawNode,
links: RawLink[],
subgraphIds: Set<string>
): WorkflowGraphNode {
const id = toNodeId(raw.id)
const type = raw.type ?? ''
return {
id,
type,
subgraphId: subgraphIds.has(type) ? type : null,
hasOutgoingLinks: (raw.outputs ?? []).some(
(o) => (o.links?.length ?? 0) > 0
),
inputs: (raw.inputs ?? []).map((input) => ({
name: input.name ?? '',
origin: originFor(input, links)
}))
}
}
/** Narrows the loosely-typed serialized workflow into the resolver's view. */
export function fromWorkflowJson(workflow: ComfyWorkflowJSON): WorkflowGraph {
const raw: RawWorkflow = workflow
const definitions = raw.definitions?.subgraphs ?? []
const subgraphIds = new Set(definitions.map((d) => d.id))
const topLinks = readLinks(raw.links)
const subgraphs: WorkflowSubgraph[] = definitions.map((def) => {
const innerLinks = readLinks(def.links)
return {
id: def.id,
nodes: (def.nodes ?? []).map((n) => readNode(n, innerLinks, subgraphIds)),
exposedInputs: (def.inputs ?? []).map((input, slot) => ({
name: input.name ?? '',
type: input.type ?? '',
label: input.label ?? null,
slot
}))
}
})
return {
nodes: (raw.nodes ?? []).map((n) => readNode(n, topLinks, subgraphIds)),
subgraphs
}
}

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const canvasObj = {
setGraph: vi.fn()
}
const mocks = vi.hoisted(() => ({
canvas: null as { setGraph: ReturnType<typeof vi.fn> } | null,
rootGraph: { id: 'root' }
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: mocks.canvas })
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mocks.rootGraph
}
}
}))
import { restoreView } from './subgraphNavigation'
describe('subgraphNavigation', () => {
beforeEach(() => {
canvasObj.setGraph.mockReset()
mocks.canvas = canvasObj
})
it('returns the canvas to the root graph', () => {
restoreView()
expect(canvasObj.setGraph).toHaveBeenCalledWith(mocks.rootGraph)
})
it('does not throw without a canvas', () => {
mocks.canvas = null
expect(() => restoreView()).not.toThrow()
})
})

View File

@@ -0,0 +1,11 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
/**
* Return the canvas to the root graph. Called defensively before each step in
* case the user opened a subgraph manually — view-only, no graph mutation
* (ADR-0008). The tour itself never enters a subgraph.
*/
export function restoreView() {
useCanvasStore().canvas?.setGraph(app.rootGraph)
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { ResolvedRoles } from './tourSequence'
import { sequenceBuilder, shapeOf } from './tourSequence'
function nodeRole(id: number) {
return { nodeId: toNodeId(id) }
}
function promptRole(subgraphId: number, innerId: number) {
return {
subgraphNodeId: toNodeId(subgraphId),
innerNodeId: toNodeId(innerId),
widgetName: 'text',
portFallback: 'prompt'
}
}
const i2vRoles: ResolvedRoles = {
source: nodeRole(97),
prompt: promptRole(129, 93),
engine: nodeRole(86),
sink: nodeRole(108),
mediaKind: 'video'
}
const t2iRoles: ResolvedRoles = {
source: null,
prompt: promptRole(57, 27),
engine: nodeRole(3),
sink: nodeRole(9),
mediaKind: 'image'
}
describe('sequenceBuilder', () => {
it('builds Upload → Prompt → Run → Result for an I2V graph', () => {
const steps = sequenceBuilder(i2vRoles)
expect(steps.map((s) => s.kind)).toEqual([
'upload',
'prompt',
'run',
'result'
])
})
it('omits the Upload step when there is no source (T2I)', () => {
const steps = sequenceBuilder(t2iRoles)
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
})
it('targets the source node on the Upload step', () => {
const upload = sequenceBuilder(i2vRoles).find((s) => s.kind === 'upload')
expect(upload?.nodeId).toBe(toNodeId(97))
})
it('carries the prompt path on the Prompt step', () => {
const prompt = sequenceBuilder(t2iRoles).find((s) => s.kind === 'prompt')
expect(prompt?.prompt).toEqual(promptRole(57, 27))
})
it('carries the sink and media kind on the Result step', () => {
const result = sequenceBuilder(i2vRoles).find((s) => s.kind === 'result')
expect(result?.nodeId).toBe(toNodeId(108))
expect(result?.mediaKind).toBe('video')
})
it('omits the Prompt step when the prompt role is unresolved', () => {
const steps = sequenceBuilder({ ...t2iRoles, prompt: null })
expect(steps.map((s) => s.kind)).toEqual(['run', 'result'])
})
it('omits the Result step when the sink role is unresolved', () => {
const steps = sequenceBuilder({ ...t2iRoles, sink: null })
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run'])
})
it('always includes the Run step, which targets no node', () => {
const run = sequenceBuilder(t2iRoles).find((s) => s.kind === 'run')
expect(run).toBeDefined()
expect(run?.nodeId).toBeNull()
})
it('tags the Prompt and Upload steps with the shape that picks their copy', () => {
const steps = sequenceBuilder(i2vRoles)
expect(steps.find((s) => s.kind === 'upload')?.shape).toBe('i2v')
expect(steps.find((s) => s.kind === 'prompt')?.shape).toBe('i2v')
})
})
describe('shapeOf', () => {
it('is t2i when nothing feeds the prompt', () => {
expect(shapeOf(t2iRoles)).toBe('t2i')
})
it('is i2v when a source image drives video', () => {
expect(shapeOf(i2vRoles)).toBe('i2v')
})
it('is image-edit when a source image drives an image', () => {
// The prompt edits the picture rather than describing a new one, so this
// must not collapse into t2i: the copy would tell the wrong story.
expect(shapeOf({ ...i2vRoles, mediaKind: 'image' })).toBe('image-edit')
})
it('is other when the tour cannot tell what the workflow does', () => {
expect(shapeOf({ ...t2iRoles, sink: null })).toBe('other')
expect(shapeOf({ ...t2iRoles, prompt: null })).toBe('other')
})
})

View File

@@ -0,0 +1,96 @@
import type {
OnboardingTourShape,
OnboardingTourStepKey
} from '@/platform/telemetry/types'
import type { NodeId } from '@/types/nodeId'
/**
* The kind of media a resolved sink produces, used to pick the Result step's
* renderer (image vs video player) and to shape telemetry.
*/
export type MediaKind = 'image' | 'video'
/**
* How to reach the editable prompt widget. The widget is (in every known
* template) nested inside a subgraph node, so the resolver returns the path to
* it plus the subgraph's exposed `prompt` input port as a fallback spotlight
* target when programmatic focus of the inner widget fails.
*/
export interface PromptRole {
subgraphNodeId: NodeId
innerNodeId: NodeId
widgetName: string
/** Name of the exposed input port on the collapsed subgraph node. */
portFallback: string | null
}
/** A resolved graph node targeted by a tour step. */
export interface NodeRole {
nodeId: NodeId
}
/**
* The roles the resolver extracts from a loaded graph. Any role may be null
* when it cannot be resolved; the sequence builder omits the corresponding
* step rather than failing (graceful degradation).
*/
export interface ResolvedRoles {
/** Top-level input image node (absent for text-to-image → Upload step skipped). */
source: NodeRole | null
prompt: PromptRole | null
engine: NodeRole | null
sink: NodeRole | null
mediaKind: MediaKind
}
/** Step kinds are the telemetry step keys, so the two can never drift. */
type TourStepKind = OnboardingTourStepKey
/**
* What the workflow does, derived from the roles that resolved. Drives both the
* step copy (the prompt means something different in each) and telemetry tags.
*/
export function shapeOf(roles: ResolvedRoles): OnboardingTourShape {
if (!roles.prompt || !roles.sink) return 'other'
if (!roles.source) return 't2i'
return roles.mediaKind === 'video' ? 'i2v' : 'image-edit'
}
export interface TourStep {
kind: TourStepKind
/** Spotlight target; null for Run, which points at the toolbar button. */
nodeId: NodeId | null
/** The subgraph-aware path to the editable prompt widget. */
prompt?: PromptRole
/** Picks the Result renderer. */
mediaKind?: MediaKind
/** Picks the Upload/Prompt copy. */
shape?: OnboardingTourShape
}
/**
* Turns resolved roles into the ordered step list by shape: an Upload step only
* when a source image resolved, then always Prompt → Run → Result. A role that
* failed to resolve omits its step instead of crashing.
*/
export function sequenceBuilder(roles: ResolvedRoles): TourStep[] {
const steps: TourStep[] = []
const shape = shapeOf(roles)
if (roles.source) {
steps.push({ kind: 'upload', nodeId: roles.source.nodeId, shape })
}
if (roles.prompt) {
steps.push({ kind: 'prompt', nodeId: null, prompt: roles.prompt, shape })
}
steps.push({ kind: 'run', nodeId: null })
if (roles.sink) {
steps.push({
kind: 'result',
nodeId: roles.sink.nodeId,
mediaKind: roles.mediaKind
})
}
return steps
}

View File

@@ -0,0 +1,56 @@
import type { CuratedTemplateId } from './roleResolver'
/**
* The curated templates the Getting Started screen loads. Tutorial thumbnails are
* drawn from this set so they only ever reference a template that resolves.
*/
export const CURATED_TEMPLATE_IDS = [
'image_krea2_turbo_t2i',
'image_z_image_turbo',
'video_ltx2_3_i2v',
'video_wan2_2_14B_i2v'
] as const satisfies readonly CuratedTemplateId[]
type LoadedTemplateId = (typeof CURATED_TEMPLATE_IDS)[number]
/**
* Tutorial cards on the Getting Started "Tutorials" tab. Each reuses a loaded
* template's server-served thumbnail for its image and opens `url` in a new tab.
* Thumbnails are offset from the Templates tab's order so the two grids don't
* read as the same four images in the same places.
*/
export interface TutorialCard {
id: string
titleKey: string
url: string
thumbnailTemplate: LoadedTemplateId
}
export const TUTORIAL_BADGE_ICON = 'icon-[lucide--graduation-cap]'
export const tutorialCards: readonly TutorialCard[] = [
{
id: 'interface-overview',
titleKey: 'onboardingTour.gettingStarted.tutorials.interfaceOverview',
url: 'https://docs.comfy.org/interface/overview',
thumbnailTemplate: 'image_krea2_turbo_t2i'
},
{
id: 'text-to-image',
titleKey: 'onboardingTour.gettingStarted.tutorials.textToImage',
url: 'https://docs.comfy.org/tutorials/basic/text-to-image',
thumbnailTemplate: 'video_ltx2_3_i2v'
},
{
id: 'image-to-image',
titleKey: 'onboardingTour.gettingStarted.tutorials.imageToImage',
url: 'https://docs.comfy.org/tutorials/basic/image-to-image',
thumbnailTemplate: 'video_wan2_2_14B_i2v'
},
{
id: 'inpaint',
titleKey: 'onboardingTour.gettingStarted.tutorials.inpaint',
url: 'https://docs.comfy.org/tutorials/basic/inpaint',
thumbnailTemplate: 'image_z_image_turbo'
}
]

View File

@@ -0,0 +1,851 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { effectScope, nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { toNodeId } from '@/types/nodeId'
import type * as CanvasSpotlightAdapter from './canvasSpotlightAdapter'
import type { PromptRole, ResolvedRoles, TourStep } from './tourSequence'
const activeState = {} as ComfyWorkflowJSON
const promptRole: PromptRole = {
subgraphNodeId: toNodeId(10),
innerNodeId: toNodeId(27),
widgetName: 'text',
portFallback: 'prompt'
}
const imageEditRoles: ResolvedRoles = {
source: { nodeId: toNodeId(1) },
prompt: promptRole,
engine: { nodeId: toNodeId(3) },
sink: { nodeId: toNodeId(9) },
mediaKind: 'image'
}
const mocks = vi.hoisted(() => ({
isCloud: true,
isSubscriptionEnabled: vi.fn(() => true),
isNewUser: vi.fn<() => boolean | null>(() => true),
onboardingTourEnabled: true,
tutorialCompleted: false as boolean,
resolveTourRoles: vi.fn(),
restoreView: vi.fn(),
nodesPresent: vi.fn(() => true),
canvasTransformValid: vi.fn(() => true),
activeWorkflowState: {} as ComfyWorkflowJSON | null,
storeStart: vi.fn(),
storeAdvance: vi.fn(),
storeEnd: vi.fn(),
steps: [] as TourStep[],
stepIndex: { value: 0 },
phase: { value: 'active' as 'idle' | 'active' },
resolvedRoles: { value: null as ResolvedRoles | null },
telemetry: {
trackOnboardingTourStarted: vi.fn(),
trackOnboardingTourStepViewed: vi.fn(),
trackOnboardingTourSkipped: vi.fn(),
trackOnboardingTourCompleted: vi.fn(),
trackOnboardingTourRunTriggered: vi.fn(),
trackOnboardingTourUpgradeShown: vi.fn()
},
hasFunds: true as boolean,
showSubscriptionDialog: vi.fn(),
storeShowNudge: vi.fn(),
storeCaptureResultMedia: vi.fn()
}))
const upgradeModalOpen = await vi.hoisted(async () => {
const { ref } = await import('vue')
return ref(false)
})
/** Captures the api-bus handlers the controller registers so tests can dispatch a specific run outcome. */
type ApiEventHandler = (event: CustomEvent) => void
const apiEventHandlers = vi.hoisted(() => new Map<string, ApiEventHandler>())
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn((event: string, handler: ApiEventHandler) => {
apiEventHandlers.set(event, handler)
}),
removeEventListener: vi.fn((event: string) => {
apiEventHandlers.delete(event)
})
}
}))
function emitExecution(event: string) {
apiEventHandlers.get(event)?.(new CustomEvent(event, { detail: {} }))
}
/** Simulate a run finishing successfully. */
async function runJob() {
emitExecution('execution_success')
await nextTick()
}
/** Simulate a run ending with a non-success outcome. */
async function failJob(event: 'execution_error' | 'execution_interrupted') {
emitExecution(event)
await nextTick()
}
/** Click the toolbar Run button (the Run step's advance trigger). */
function clickRunButton() {
const button = document.createElement('button')
button.setAttribute('data-testid', 'queue-button')
document.body.append(button)
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
button.remove()
}
/** Click an element that is not the Run button (must not advance the step). */
function clickElsewhere() {
const el = document.createElement('div')
document.body.append(el)
el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
el.remove()
}
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mocks.isCloud
}
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isSubscriptionEnabled: mocks.isSubscriptionEnabled
})
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => ({ isNewUser: mocks.isNewUser })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get onboardingTourEnabled() {
return mocks.onboardingTourEnabled
}
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.TutorialCompleted' ? mocks.tutorialCompleted : undefined,
set: vi.fn()
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
subscription: {
get value() {
return { hasFunds: mocks.hasFunds }
}
},
showSubscriptionDialog: mocks.showSubscriptionDialog
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
isDialogOpen: () => upgradeModalOpen.value
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
get activeState() {
return mocks.activeWorkflowState
}
}
})
}))
vi.mock('./subgraphNavigation', () => ({
restoreView: mocks.restoreView
}))
vi.mock('./roleResolution', () => ({
resolveTourRoles: mocks.resolveTourRoles
}))
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<typeof CanvasSpotlightAdapter>()),
nodesPresent: mocks.nodesPresent,
canvasTransformValid: mocks.canvasTransformValid
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mocks.telemetry
}))
vi.mock('./onboardingTourStore', () => ({
isUpgradeModalOpen: () => upgradeModalOpen.value,
useOnboardingTourStore: () => ({
start: mocks.storeStart,
advance: mocks.storeAdvance,
back: vi.fn(),
end: mocks.storeEnd,
showNudge: mocks.storeShowNudge,
captureResultMedia: mocks.storeCaptureResultMedia,
get phase() {
return mocks.phase.value
},
get steps() {
return mocks.steps
},
get stepIndex() {
return mocks.stepIndex.value
},
get currentStep() {
return mocks.steps[mocks.stepIndex.value] ?? null
},
get totalSteps() {
return mocks.steps.length
},
get resolvedRoles() {
return mocks.resolvedRoles.value
}
})
}))
import { useOnboardingTourController } from './useOnboardingTourController'
describe('useOnboardingTourController.shouldStartTour', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.tutorialCompleted = false
mocks.activeWorkflowState = activeState
mocks.steps = []
mocks.stepIndex.value = 0
})
afterEach(() => {
vi.clearAllMocks()
})
it('starts when every condition holds', () => {
expect(useOnboardingTourController().shouldStartTour()).toBe(true)
})
it('does not start off the Cloud build', () => {
mocks.isCloud = false
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
})
it('does not start when subscription mode is off', () => {
mocks.isSubscriptionEnabled.mockReturnValue(false)
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
})
it('does not start for a returning user', () => {
mocks.isNewUser.mockReturnValue(false)
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
})
it('does not start before the new-user verdict is known', () => {
mocks.isNewUser.mockReturnValue(null)
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
})
it('does not start when the feature flag is off', () => {
mocks.onboardingTourEnabled = false
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
})
it('still starts when the tutorial flag is already set but the user is new', () => {
mocks.tutorialCompleted = true
mocks.isNewUser.mockReturnValue(true)
expect(useOnboardingTourController().shouldStartTour()).toBe(true)
})
})
describe('useOnboardingTourController.start', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.tutorialCompleted = false
mocks.activeWorkflowState = activeState
mocks.storeStart.mockReset()
mocks.storeEnd.mockReset()
mocks.storeAdvance.mockReset()
mocks.steps = []
mocks.stepIndex.value = 0
mocks.phase.value = 'active'
mocks.resolvedRoles.value = imageEditRoles
mocks.hasFunds = true
mocks.showSubscriptionDialog.mockReset()
upgradeModalOpen.value = false
mocks.storeShowNudge.mockReset()
mocks.telemetry.trackOnboardingTourRunTriggered.mockReset()
mocks.telemetry.trackOnboardingTourUpgradeShown.mockReset()
mocks.storeCaptureResultMedia.mockReset()
apiEventHandlers.clear()
})
afterEach(() => {
useOnboardingTourController().end('skip')
vi.useRealTimers()
vi.clearAllMocks()
})
it('does not start the store when gating fails', async () => {
mocks.isNewUser.mockReturnValue(false)
await useOnboardingTourController().start()
expect(mocks.storeStart).not.toHaveBeenCalled()
})
it('serializes the active workflow and starts the store', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start('image_z_image_turbo')
expect(mocks.storeStart).toHaveBeenCalledWith(
activeState,
'image_z_image_turbo'
)
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'image_z_image_turbo',
shape: 'image-edit'
})
)
})
it('reports the resolved shape (t2i has no source image)', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.resolvedRoles.value = { ...imageEditRoles, source: null }
await useOnboardingTourController().start()
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({ shape: 't2i' })
)
})
it('defaults the entry to getting_started when none is passed', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start('image_z_image_turbo')
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({ entry: 'getting_started' })
)
})
it('threads the template_url entry for a ?template= arrival', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start(
'image_z_image_turbo',
'template_url'
)
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'image_z_image_turbo',
entry: 'template_url'
})
)
})
it('threads the share_url entry with no template id for a ?share= arrival', async () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.resolvedRoles.value = { ...imageEditRoles, prompt: null, sink: null }
await useOnboardingTourController().start(undefined, 'share_url')
expect(mocks.storeStart).toHaveBeenCalledWith(activeState, undefined)
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({
template_id: undefined,
shape: 'other',
entry: 'share_url'
})
)
})
it('ends the tour when no active workflow is present', async () => {
mocks.activeWorkflowState = null
await useOnboardingTourController().start()
expect(mocks.storeStart).not.toHaveBeenCalled()
expect(mocks.storeEnd).toHaveBeenCalled()
})
it('reports completion when the tour finishes', () => {
mocks.steps = [{ kind: 'run', nodeId: null }]
useOnboardingTourController().end('done')
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalled()
})
it('spotlights the collapsed port on the prompt step without entering a subgraph', async () => {
mocks.steps = [{ kind: 'prompt', nodeId: null, prompt: promptRole }]
await useOnboardingTourController().start()
// The tour never opens the subgraph; the prompt step restores the root view
// so the collapsed host's exposed port stays spotlit.
expect(mocks.restoreView).toHaveBeenCalled()
})
it('never auto-advances a step — the user always drives Next', async () => {
vi.useFakeTimers()
// Even a purely informational step (upload) must not advance on its own.
mocks.steps = [
{ kind: 'upload', nodeId: toNodeId(1) },
{ kind: 'prompt', nodeId: null, prompt: promptRole }
]
await useOnboardingTourController().start()
await vi.advanceTimersByTimeAsync(60000)
expect(mocks.storeAdvance).not.toHaveBeenCalled()
})
it('gates a no-funds user at the Run step: upgrade modal, nudge, end', async () => {
mocks.hasFunds = false
mocks.steps = [{ kind: 'run', nodeId: null }]
// The gate opens the real upgrade modal, so the paywall watcher fires too;
// ending must stay one-shot across both paths.
mocks.showSubscriptionDialog.mockImplementation(() => {
upgradeModalOpen.value = true
})
await useOnboardingTourController().start('image_z_image_turbo')
await nextTick()
expect(mocks.showSubscriptionDialog).toHaveBeenCalledWith({
reason: 'out_of_credits'
})
expect(
mocks.telemetry.trackOnboardingTourUpgradeShown
).toHaveBeenCalledWith(
expect.objectContaining({ template_id: 'image_z_image_turbo' })
)
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
expect(mocks.storeEnd).toHaveBeenCalledOnce()
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalledOnce()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).not.toHaveBeenCalled()
})
it('gates via store.end when the user cannot fund a run', async () => {
mocks.hasFunds = false
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start()
expect(mocks.storeEnd).toHaveBeenCalled()
})
it('lets a funded user reach the Run step without reporting a run yet', async () => {
mocks.hasFunds = true
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start()
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
expect(mocks.storeEnd).not.toHaveBeenCalled()
// Merely arriving at the Run step is not a run — telemetry waits for a real
// execution so nothing can fabricate an activation event.
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).not.toHaveBeenCalled()
})
it('ends the tour when the upgrade modal opens mid-run so it never hangs', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
expect(mocks.storeEnd).not.toHaveBeenCalled()
// A no-subscription run is blocked by the paywall before it queues, so no
// execution event fires; the opening modal must end the tour on its own.
upgradeModalOpen.value = true
await nextTick()
expect(mocks.storeEnd).toHaveBeenCalled()
expect(mocks.storeShowNudge).toHaveBeenCalled()
})
it('reports the run only when a run actually completes', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
// Merely starting the tour reports nothing — the run must finish first.
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).not.toHaveBeenCalled()
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).toHaveBeenCalledWith(expect.objectContaining({ status: 'success' }))
})
it('captures the media when the run reports after the click advanced to Result', async () => {
// Production ordering: the Run click advances synchronously, so the execution
// event lands while Result is current. `advance` is a spy, so move it by hand.
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
mocks.stepIndex.value = 1
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).toHaveBeenCalledWith(expect.objectContaining({ status: 'success' }))
})
it('ignores execution events that arrive before the Run step', async () => {
// A run kicked off from an earlier step must not resolve the Result or report.
mocks.hasFunds = true
mocks.steps = [
{ kind: 'prompt', nodeId: null },
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
mocks.stepIndex.value = 0
await runJob()
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).not.toHaveBeenCalled()
})
it('reports the run at most once across repeated runs', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
await runJob()
await runJob()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).toHaveBeenCalledOnce()
})
it('reports the true status and skips media capture when a run errors', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
await failJob('execution_error')
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' }))
})
it('reports the true status and skips media capture when a run is interrupted', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
await failJob('execution_interrupted')
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
expect(
mocks.telemetry.trackOnboardingTourRunTriggered
).toHaveBeenCalledWith(expect.objectContaining({ status: 'interrupted' }))
})
it('completes the tour when a run finishes on the terminal Run step', async () => {
// No sink resolved → Run is the last step; a finished run must fire Completed.
mocks.hasFunds = true
mocks.steps = [{ kind: 'run', nodeId: null }]
await useOnboardingTourController().start('image_z_image_turbo')
await runJob()
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalledOnce()
})
it('does not complete on run when a Result step follows', async () => {
mocks.hasFunds = true
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start('image_z_image_turbo')
await runJob()
expect(mocks.telemetry.trackOnboardingTourCompleted).not.toHaveBeenCalled()
})
it('advances off the Run step the instant the Run button is clicked', async () => {
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
expect(mocks.storeAdvance).not.toHaveBeenCalled()
clickRunButton()
// The click alone advances — no waiting for the run to finish.
expect(mocks.storeAdvance).toHaveBeenCalledOnce()
})
it('does not advance when a click misses the Run button', async () => {
// The listener must discriminate on the selector, not advance on any click.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
clickElsewhere()
expect(mocks.storeAdvance).not.toHaveBeenCalled()
})
it('captures the media when the run finishes (not the step advance)', async () => {
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
await useOnboardingTourController().start()
await runJob()
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
})
it('still advances after the launching component unmounts', async () => {
// start() runs inside the Getting Started screen's setup, which unmounts right
// after; the Run-click listener must survive that teardown.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
const launchingScope = effectScope()
await launchingScope.run(async () => {
await useOnboardingTourController().start()
})
launchingScope.stop() // the Getting Started screen unmounts
clickRunButton()
expect(mocks.storeAdvance).toHaveBeenCalledOnce()
})
it('does not advance on the Result step when a run completes', async () => {
// Started already on Result: no Run-click listener is bound, so a completing
// run resolves media but never advances the step.
mocks.steps = [
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
]
mocks.stepIndex.value = 1
await useOnboardingTourController().start()
await runJob()
expect(mocks.storeAdvance).not.toHaveBeenCalled()
})
it('only gates when the funds check reaches the Run step', async () => {
mocks.hasFunds = false
mocks.steps = [
{ kind: 'prompt', nodeId: null, prompt: promptRole },
{ kind: 'run', nodeId: null }
]
await useOnboardingTourController().start()
// First step is Prompt, not Run — no gate yet.
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
})
})
describe('useOnboardingTourController post-run nudge', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.activeWorkflowState = activeState
mocks.storeStart.mockReset()
mocks.storeShowNudge.mockReset()
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.stepIndex.value = 0
mocks.phase.value = 'active'
mocks.hasFunds = true
apiEventHandlers.clear()
})
afterEach(() => {
useOnboardingTourController().end('skip')
vi.clearAllMocks()
})
it('surfaces the nudge on the first completed run after the tour starts', async () => {
await useOnboardingTourController().start('image_z_image_turbo')
await runJob()
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
})
it('surfaces the nudge only once even across repeated runs', async () => {
await useOnboardingTourController().start('image_z_image_turbo')
await runJob()
await runJob()
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
})
it('does not surface the nudge before any tour has started', async () => {
// No tour is live, so no run watcher is registered; a stray job completion
// must not pop the nudge.
await runJob()
expect(mocks.storeShowNudge).not.toHaveBeenCalled()
})
})
describe('useOnboardingTourController.beginTour', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mocks.isCloud = true
mocks.isSubscriptionEnabled.mockReturnValue(true)
mocks.isNewUser.mockReturnValue(true)
mocks.onboardingTourEnabled = true
mocks.activeWorkflowState = activeState
mocks.resolveTourRoles.mockReturnValue(imageEditRoles)
mocks.nodesPresent.mockReturnValue(true)
mocks.canvasTransformValid.mockReturnValue(true)
mocks.storeStart.mockReset()
mocks.steps = [{ kind: 'run', nodeId: null }]
mocks.stepIndex.value = 0
mocks.phase.value = 'idle'
mocks.resolvedRoles.value = imageEditRoles
mocks.hasFunds = true
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
apiEventHandlers.clear()
})
afterEach(() => {
useOnboardingTourController().end('skip')
vi.unstubAllGlobals()
vi.useRealTimers()
vi.clearAllMocks()
})
it('starts the tour once the live graph holds the resolved nodes', async () => {
await useOnboardingTourController().beginTour({
templateId: 'image_z_image_turbo'
})
expect(mocks.storeStart).toHaveBeenCalledWith(
activeState,
'image_z_image_turbo'
)
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'image_z_image_turbo',
entry: 'getting_started'
})
)
})
it('threads the share_url entry through when launched from a URL', async () => {
await useOnboardingTourController().beginTour({ entry: 'share_url' })
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
expect.objectContaining({ entry: 'share_url' })
)
})
it('does not start when gating fails', async () => {
mocks.isNewUser.mockReturnValue(false)
await useOnboardingTourController().beginTour({ templateId: 'x' })
expect(mocks.storeStart).not.toHaveBeenCalled()
})
it('does not start a second tour while one is already active', async () => {
mocks.phase.value = 'active'
await useOnboardingTourController().beginTour({ templateId: 'x' })
expect(mocks.storeStart).not.toHaveBeenCalled()
})
it('starts degraded rather than trapping when the graph never becomes ready', async () => {
vi.useFakeTimers()
mocks.nodesPresent.mockReturnValue(false)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const pending = useOnboardingTourController().beginTour({ templateId: 'x' })
await vi.runAllTimersAsync()
await pending
expect(mocks.storeStart).toHaveBeenCalled()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})

View File

@@ -0,0 +1,337 @@
import { createSharedComposable, until, useEventListener } from '@vueuse/core'
import { computed, effectScope, ref, watch } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { api } from '@/scripts/api'
import type {
OnboardingTourEntry,
OnboardingTourRunStatus,
OnboardingTourShape
} from '@/platform/telemetry/types'
import type { OnboardingCandidateDeps } from '@/platform/workflow/persistence/onboardingEntryStore'
import { isOnboardingCandidate } from '@/platform/workflow/persistence/onboardingEntryStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNewUserService } from '@/services/useNewUserService'
import type { NodeId } from '@/types/nodeId'
import {
RUN_BUTTON_SELECTOR,
canvasTransformValid,
nodesPresent
} from './canvasSpotlightAdapter'
import { resolveTourRoles } from './roleResolution'
import {
isUpgradeModalOpen,
useOnboardingTourStore
} from './onboardingTourStore'
import type { TourEndReason } from './onboardingTourStore'
import { restoreView } from './subgraphNavigation'
import { shapeOf } from './tourSequence'
import type { ResolvedRoles } from './tourSequence'
/** Budget for the serialized snapshot to appear (guards the URL blank-load race). */
const ACTIVE_STATE_TIMEOUT_MS = 1500
/** Budget for the live canvas graph to contain the resolved role nodes. */
const READY_TIMEOUT_MS = 3000
/** Top-level nodes the sequence actually spotlights (upload, prompt host, sink). */
function roleAnchorIds(roles: ResolvedRoles): NodeId[] {
return [
roles.source?.nodeId,
roles.prompt?.subgraphNodeId,
roles.sink?.nodeId
].filter((id): id is NodeId => id != null)
}
/** The live graph holds the resolved anchors and the canvas has a usable transform. */
function liveGraphReady(roles: ResolvedRoles): boolean {
return nodesPresent(roleAnchorIds(roles)) && canvasTransformValid()
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
}
interface BeginTourOptions {
templateId?: string
entry?: OnboardingTourEntry
}
function _useOnboardingTourController() {
const store = useOnboardingTourStore()
const onboardingDeps: OnboardingCandidateDeps = {
subscription: useSubscription(),
newUserService: useNewUserService(),
featureFlags: useFeatureFlags()
}
/** True while the loader is up: template chosen, tour not yet started. */
const isPreparing = ref(false)
/** Retained from `start` so completion/skip telemetry carries the same tags. */
let activeTemplateId: string | undefined
let activeShape: OnboardingTourShape = 'other'
/** Guards RunTriggered against re-firing on a second run. */
let runReported = false
/** Stops the per-tour run watcher; set while a tour is live. */
let stopRunListener: (() => void) | undefined
/** Stops the Run-button click listener; set only while on the Run step. */
let stopRunClick: (() => void) | undefined
/** Guards the post-run outcome so only the first finished run acts. */
let runOutcomeHandled = false
/** True when the sequence has no step after Run, so a finished run ends the tour. */
function runIsTerminalStep(): boolean {
return store.steps.at(-1)?.kind === 'run'
}
/**
* Handle the run finishing: capture the media on success, report the real status,
* and surface the nudge. When Run is the last step, this completes the tour;
* otherwise the Result step does.
*/
function handleRunOutcome(status: OnboardingTourRunStatus) {
// The Run click advances to Result before the run reports, so accept either.
const step = store.currentStep?.kind
if (step !== 'run' && step !== 'result') return
if (runOutcomeHandled) return
runOutcomeHandled = true
store.runFinished = true
if (status === 'success') void store.captureResultMedia()
reportRunTriggered(status)
if (runIsTerminalStep()) {
end('done')
} else {
store.showNudge()
}
}
/**
* Watch for the run to finish, keyed to the real execution outcome rather than the
* `activeJobId` reset, which also fires on failure. Owned by a detached scope:
* `start()` runs in the Getting Started screen's setup, which unmounts right after,
* so a component-scoped listener would die before the user reaches Run.
*/
function listenForFirstRun() {
stopRunListener?.()
runOutcomeHandled = false
const scope = effectScope(true)
scope.run(() => {
useEventListener(api, 'execution_success', () =>
handleRunOutcome('success')
)
useEventListener(api, 'execution_error', () => handleRunOutcome('error'))
useEventListener(api, 'execution_interrupted', () =>
handleRunOutcome('interrupted')
)
// A no-subscription run is blocked by the paywall before it queues, so no
// execution event ever fires; end the tour when that modal opens so it
// doesn't hang on the Result step. The nudge defers itself until it closes.
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
watch(upgradeModalOpen, (open) => {
if (open && store.phase === 'active') end('done')
})
})
stopRunListener = () => scope.stop()
}
/**
* Advance on the Run click rather than on execution completion, which left the mark
* stuck on Run. Capture-phase, so it fires despite the scrim above the toolbar.
*/
function listenForRunClick() {
stopRunClick?.()
const onClick = (event: MouseEvent) => {
const target = event.target as Element | null
if (target?.closest(RUN_BUTTON_SELECTOR)) void advance()
}
document.addEventListener('click', onClick, true)
stopRunClick = () => document.removeEventListener('click', onClick, true)
}
function reportRunTriggered(status: OnboardingTourRunStatus) {
if (runReported) return
runReported = true
useTelemetry()?.trackOnboardingTourRunTriggered?.({
template_id: activeTemplateId,
shape: activeShape,
status
})
}
/** Whether the onboarding tour should run for this session. */
function shouldStartTour(): boolean {
return isOnboardingCandidate(onboardingDeps)
}
/**
* Serialize the loaded graph and run the tour on it. Aborts cleanly if gating fails
* or no workflow is active.
*
* @param entry Where the tour was launched from, for telemetry.
*/
async function start(
templateId?: string,
entry: OnboardingTourEntry = 'getting_started'
) {
if (!shouldStartTour()) return
const workflow = useWorkflowStore().activeWorkflow?.activeState
if (!workflow) {
store.end()
return
}
store.start(workflow, templateId)
activeTemplateId = templateId
const roles = store.resolvedRoles
activeShape = roles ? shapeOf(roles) : 'other'
runReported = false
listenForFirstRun()
useTelemetry()?.trackOnboardingTourStarted?.({
template_id: activeTemplateId,
shape: activeShape,
entry
})
await enterStep()
}
/**
* Shared entry point for the Getting Started card and the URL loaders. Waits until
* the template is present on the live canvas before starting, so the overlay never
* paints over an unrendered graph. On timeout it starts degraded rather than trap.
*/
async function beginTour({
templateId,
entry = 'getting_started'
}: BeginTourOptions = {}) {
if (!shouldStartTour()) return
if (isPreparing.value || store.phase === 'active') return
isPreparing.value = true
try {
const workflowStore = useWorkflowStore()
// Guards the URL path, where loadBlankWorkflow() precedes the template load.
await until(() => workflowStore.activeWorkflow?.activeState).toBeTruthy({
timeout: ACTIVE_STATE_TIMEOUT_MS,
throwOnTimeout: false
})
const workflow = workflowStore.activeWorkflow?.activeState
if (!workflow) return
// Resolve from the same snapshot start() uses, then hold until the live
// graph agrees — the spotlight reads the live graph, so they must match.
const roles = resolveTourRoles(workflow, templateId)
const ready = await until(() => liveGraphReady(roles)).toBe(true, {
timeout: READY_TIMEOUT_MS,
throwOnTimeout: false
})
if (!ready) {
console.warn(
'[onboardingTour] canvas readiness timed out; starting degraded',
{ templateId }
)
}
await nextFrame()
await start(templateId, entry)
await nextFrame()
} finally {
isPreparing.value = false
}
}
/** UX-only paywall check; real enforcement is server-side. Never gate on signup method. */
function hasFunds(): boolean {
return useBillingContext().subscription.value?.hasFunds === true
}
/**
* Gate the Run step: no-funds users get the upgrade modal and the tour ends, with
* the nudge deferring itself until that modal closes.
*
* @returns True when gated.
*/
function gateRunStep(): boolean {
if (hasFunds()) return false
useBillingContext().showSubscriptionDialog({ reason: 'out_of_credits' })
useTelemetry()?.trackOnboardingTourUpgradeShown?.({
template_id: activeTemplateId
})
end('done')
return true
}
/** Set up the current step: gate the run, arm the run-click, spotlight the prompt. */
async function enterStep() {
stopRunClick?.()
stopRunClick = undefined
const step = store.currentStep
if (!step) return
if (step.kind === 'run') {
if (gateRunStep()) return
listenForRunClick()
}
// Defensive: the tour never enters a subgraph, but the user may have opened one.
restoreView()
useTelemetry()?.trackOnboardingTourStepViewed?.({
template_id: activeTemplateId,
step_key: step.kind,
step_index: store.stepIndex,
step_total: store.totalSteps
})
}
async function advance() {
store.advance()
await enterStep()
}
async function back() {
store.back()
await enterStep()
}
function end(reason: TourEndReason) {
if (store.phase !== 'active') return
stopRunListener?.()
stopRunListener = undefined
stopRunClick?.()
stopRunClick = undefined
const telemetry = useTelemetry()
if (reason === 'skip') {
const step = store.currentStep
if (step) {
telemetry?.trackOnboardingTourSkipped?.({
template_id: activeTemplateId,
step_key: step.kind,
step_index: store.stepIndex,
step_total: store.totalSteps
})
}
} else if (reason === 'done') {
telemetry?.trackOnboardingTourCompleted?.({
template_id: activeTemplateId,
shape: activeShape
})
}
store.showNudge()
store.end()
}
return { shouldStartTour, beginTour, start, advance, back, end }
}
export const useOnboardingTourController = createSharedComposable(
_useOnboardingTourController
)

View File

@@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type * as adapterModule from './canvasSpotlightAdapter'
import { TOUR_FOCUS_DURATION_MS } from './canvasSpotlightAdapter'
type AdapterModule = typeof adapterModule
const mocks = vi.hoisted(() => ({
transformKey: 'a' as string | null
}))
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<AdapterModule>()),
canvasTransformKey: () => mocks.transformKey
}))
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
const INTRO_PREVIEW_MS = 500
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
function setup(options: { reduceMotion?: boolean; isStatic?: boolean } = {}) {
const frameTarget = vi.fn()
const choreography = useTourChoreography({
reduceMotion: ref(options.reduceMotion ?? false),
isStatic: ref(options.isStatic ?? false),
frameTarget
})
return { ...choreography, frameTarget }
}
/** Drive the rAF sampler until the real trackSettle sees a still transform. */
function sampleUntilSettled(sampleFrame: () => void) {
for (let i = 0; i < 4; i++) sampleFrame()
}
describe('useTourChoreography', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.transformKey = 'a'
})
afterEach(() => {
vi.useRealTimers()
})
it('holds the framing until the mark has glided', () => {
const { beginStep, frameTarget } = setup()
beginStep()
expect(frameTarget).not.toHaveBeenCalled()
vi.advanceTimersByTime(MARK_GLIDE_MS)
expect(frameTarget).toHaveBeenCalledOnce()
})
it('reveals the copy once the canvas transform holds still', () => {
const { beginStep, sampleFrame, copyVisible, cameraSettled } = setup()
beginStep()
vi.advanceTimersByTime(MARK_GLIDE_MS)
expect(copyVisible.value).toBe(false)
sampleUntilSettled(sampleFrame)
expect(copyVisible.value).toBe(true)
expect(cameraSettled.value).toBe(true)
})
it('keeps the copy hidden while the camera is still moving', () => {
const { beginStep, sampleFrame, copyVisible } = setup()
beginStep()
vi.advanceTimersByTime(MARK_GLIDE_MS)
for (const key of ['b', 'c', 'd', 'e']) {
mocks.transformKey = key
sampleFrame()
}
expect(copyVisible.value).toBe(false)
})
it('releases the copy on the watchdog when the camera never settles', () => {
const { beginStep, sampleFrame, copyVisible } = setup()
beginStep()
vi.advanceTimersByTime(MARK_GLIDE_MS)
mocks.transformKey = 'moving'
sampleFrame()
expect(copyVisible.value).toBe(false)
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
expect(copyVisible.value).toBe(true)
})
it('unpins the mark whenever the canvas moves under it', () => {
const { beginStep, sampleFrame, markGlides } = setup()
beginStep()
expect(markGlides.value).toBe(true)
mocks.transformKey = 'moved'
sampleFrame()
expect(markGlides.value).toBe(false)
})
it('frames a static step immediately and shows its copy without waiting', () => {
const { beginStep, frameTarget, copyVisible } = setup({ isStatic: true })
beginStep()
expect(copyVisible.value).toBe(true)
expect(frameTarget).not.toHaveBeenCalled()
})
it('previews the workflow undimmed before dimming to the first step', () => {
const { openTour, revealed, frameTarget } = setup()
openTour()
expect(revealed.value).toBe(false)
vi.advanceTimersByTime(INTRO_PREVIEW_MS)
expect(revealed.value).toBe(true)
// The first step frames without a glide delay: there is no prior mark to move.
expect(frameTarget).toHaveBeenCalledOnce()
})
it('skips the preview and the glide when motion is reduced', () => {
const { openTour, revealed, copyVisible, frameTarget } = setup({
reduceMotion: true
})
openTour()
expect(revealed.value).toBe(true)
expect(copyVisible.value).toBe(true)
expect(frameTarget).not.toHaveBeenCalled()
})
it('hides the copy again on a step change', () => {
const { beginStep, sampleFrame, resetStep, copyVisible, cameraSettled } =
setup()
beginStep()
vi.advanceTimersByTime(MARK_GLIDE_MS)
sampleUntilSettled(sampleFrame)
expect(copyVisible.value).toBe(true)
resetStep()
expect(copyVisible.value).toBe(false)
expect(cameraSettled.value).toBe(false)
})
it('drops a pending framing when the step is reset before it runs', () => {
const { beginStep, resetStep, frameTarget } = setup()
beginStep()
resetStep()
vi.advanceTimersByTime(MARK_GLIDE_MS + SETTLE_WATCHDOG_MS)
expect(frameTarget).not.toHaveBeenCalled()
})
it('stops sampling for a settle once the step is reset', () => {
const { beginStep, resetStep, isAwaitingSettle } = setup()
beginStep()
expect(isAwaitingSettle()).toBe(true)
resetStep()
expect(isAwaitingSettle()).toBe(false)
})
it('leaves no timer able to reveal a scrim after the tour ends', () => {
const { openTour, endTour, revealed } = setup()
openTour()
endTour()
vi.advanceTimersByTime(INTRO_PREVIEW_MS + SETTLE_WATCHDOG_MS)
expect(revealed.value).toBe(false)
})
it('leaves no watchdog able to fire after the tour ends', () => {
const { beginStep, endTour, copyVisible } = setup()
beginStep()
vi.advanceTimersByTime(MARK_GLIDE_MS)
endTour()
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
expect(copyVisible.value).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
})

View File

@@ -0,0 +1,141 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import {
INITIAL_SETTLE,
TOUR_FOCUS_DURATION_MS,
canvasTransformKey,
trackSettle
} from './canvasSpotlightAdapter'
import type { SettleState } from './canvasSpotlightAdapter'
/** Undimmed preview of the whole workflow before the tour dims to the first target. */
const INTRO_PREVIEW_MS = 500
/** The camera waits this out, so the mark's glide and the framing never run at once. */
export const MARK_GLIDE_MS = 400
/** A camera that never settles (the user keeps panning) must not strand the copy. */
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
interface ChoreographyOptions {
reduceMotion: Ref<boolean>
/** Frame the current step's target. Called once the mark has finished gliding. */
frameTarget: () => void
/** True when the step points at the toolbar, so the camera must not move. */
isStatic: Ref<boolean>
}
/**
* Sequences a step's motion so only one thing moves at a time: the mark glides, the
* camera frames it, then the copy fades in once the transform holds still.
*
* The camera is watched rather than counted out: `animateToBounds` reports no
* completion and cannot be cancelled, so an unchanged transform is the only honest
* "it stopped" signal. A watchdog bounds that wait.
*/
export function useTourChoreography({
reduceMotion,
frameTarget,
isStatic
}: ChoreographyOptions) {
/** Scrim and rings are drawn; false during the intro preview. */
const revealed = ref(false)
const copyVisible = ref(false)
/** True once this step's framing is final, so the mark's side can latch. */
const cameraSettled = ref(false)
/** Pinned while the canvas moves, so the mark rides it rather than chasing it. */
const markGlides = ref(false)
let timers: ReturnType<typeof setTimeout>[] = []
let watchdogTimer: ReturnType<typeof setTimeout> | null = null
let settle: SettleState = INITIAL_SETTLE
let awaitingSettle = false
let lastTransformKey: string | null = null
function after(ms: number, run: () => void) {
timers.push(setTimeout(run, ms))
}
function clearTimers() {
for (const timer of timers) clearTimeout(timer)
timers = []
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
watchdogTimer = null
}
/**
* Cancels only the watchdog, never a pending framing: on a step whose transform is
* already at rest the camera settles before it has moved, and the framing must still run.
*/
function showCopy() {
awaitingSettle = false
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
watchdogTimer = null
cameraSettled.value = true
copyVisible.value = true
}
function sampleFrame() {
const key = canvasTransformKey()
if (key !== lastTransformKey) {
lastTransformKey = key
markGlides.value = false
}
if (!awaitingSettle) return
settle = trackSettle(settle, key)
if (settle.settled) showCopy()
}
function beginStep({ glideFirst = true } = {}) {
revealed.value = true
if (isStatic.value || reduceMotion.value) {
markGlides.value = !reduceMotion.value
showCopy()
return
}
markGlides.value = true
awaitingSettle = true
settle = INITIAL_SETTLE
const delay = glideFirst ? MARK_GLIDE_MS : 0
if (delay === 0) frameTarget()
else after(delay, frameTarget)
watchdogTimer = setTimeout(showCopy, delay + SETTLE_WATCHDOG_MS)
}
function resetStep() {
clearTimers()
awaitingSettle = false
copyVisible.value = false
cameraSettled.value = false
}
/** Open a fresh tour on the undimmed workflow before dimming to the first target. */
function openTour() {
resetStep()
revealed.value = false
if (reduceMotion.value) return beginStep({ glideFirst: false })
after(INTRO_PREVIEW_MS, () => beginStep({ glideFirst: false }))
}
function endTour() {
resetStep()
revealed.value = false
}
return {
revealed,
copyVisible,
cameraSettled,
markGlides,
/** True while the camera is still being waited on, so a resize must not re-frame. */
isAwaitingSettle: () => awaitingSettle,
sampleFrame,
beginStep,
resetStep,
openTour,
endTour,
clearTimers
}
}

View File

@@ -0,0 +1,163 @@
import { useRafFn, useWindowSize } from '@vueuse/core'
import { computed, reactive, ref, toRef } from 'vue'
import type { Ref } from 'vue'
import {
ACTIONBAR_SELECTOR,
RUN_BUTTON_SELECTOR,
canvasViewport,
maskRectsFor,
rectIntersectsViewport
} from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import type { NodeId } from '@/types/nodeId'
function domClientRect(selector: string): ScreenRect | null {
const el = document.querySelector(selector)
if (!el) return null
const { left, top, width, height } = el.getBoundingClientRect()
return { left, top, width, height }
}
/**
* Assigning primitives rather than replacing the object keeps Vue's equality check in
* play: a stationary canvas writes identical numbers and nothing recomputes. Replacing
* it every frame re-rendered the overlay at 60fps at rest.
*/
function assignRect(target: ScreenRect, rect: ScreenRect): void {
target.left = rect.left
target.top = rect.top
target.width = rect.width
target.height = rect.height
}
/** Whether two rect lists hold the same geometry, so an unchanged frame can bail. */
function sameRects(
a: readonly ScreenRect[],
b: readonly ScreenRect[]
): boolean {
return (
a.length === b.length &&
a.every((rect, i) => {
const other = b[i]
return (
rect.left === other.left &&
rect.top === other.top &&
rect.width === other.width &&
rect.height === other.height
)
})
)
}
interface SpotlightRectsOptions {
isRunStep: Ref<boolean>
isResultStep: Ref<boolean>
revealedNodeIds: Ref<Set<NodeId>>
spotlitNodeIds: Ref<Set<NodeId>>
/** Per-frame hook for the choreography, which samples the same transform. */
onFrame?: () => void
}
/**
* Per-frame source of the rects the overlay draws: the scrim's holes, the current
* step's rings, and the region the mark may occupy.
*
* Rects are only published when the geometry actually changes, so a still canvas costs
* one layout read per frame and no re-render.
*/
export function useTourSpotlightRects({
isRunStep,
isResultStep,
revealedNodeIds,
spotlitNodeIds,
onFrame
}: SpotlightRectsOptions) {
const holeRects = ref<ScreenRect[]>([])
const spotRects = ref<ScreenRect[]>([])
/**
* The canvas rect, not the window: the app insets the canvas below the top bar and
* beside the panels, so placing against the window put the mark under that chrome.
* Falls back to the window until the canvas exists.
*/
const { width: windowWidth, height: windowHeight } = useWindowSize()
const canvasRect = reactive<ScreenRect>({
left: 0,
top: 0,
width: 0,
height: 0
})
const hasCanvasRect = ref(false)
const viewport = computed<ScreenRect>(() =>
hasCanvasRect.value
? canvasRect
: {
left: 0,
top: 0,
width: windowWidth.value,
height: windowHeight.value
}
)
function readRects() {
const canvas = canvasViewport()
hasCanvasRect.value = canvas !== null
if (canvas) assignRect(canvasRect, canvas)
if (isRunStep.value) {
const rect = domClientRect(RUN_BUTTON_SELECTOR)
return { holes: rect ? [rect] : [], spots: rect ? [rect] : [] }
}
const holes = maskRectsFor([...revealedNodeIds.value])
const spots = maskRectsFor([...spotlitNodeIds.value])
// Result cuts the toolbar out of the scrim unringed, so the eye stays on the node.
const toolbar = isResultStep.value
? domClientRect(ACTIONBAR_SELECTOR)
: null
if (toolbar) holes.push(toolbar)
return { holes, spots }
}
function recompute() {
const { holes, spots } = readRects()
if (!sameRects(holes, holeRects.value)) holeRects.value = holes
if (!sameRects(spots, spotRects.value)) spotRects.value = spots
}
const { pause, resume } = useRafFn(
() => {
recompute()
onFrame?.()
},
{ immediate: false }
)
function start() {
recompute()
resume()
}
function stop() {
pause()
holeRects.value = []
spotRects.value = []
}
return {
holeRects: toRef(() => holeRects.value),
spotRects: toRef(() => spotRects.value),
viewport,
/** Nodes off the canvas region are not ringed; the mark holds instead of chasing. */
visibleSpotRects: computed(() =>
spotRects.value.filter((rect) =>
rectIntersectsViewport(rect, viewport.value)
)
),
focusRect: computed(() => spotRects.value[0] ?? null),
recompute,
start,
stop
}
}

View File

@@ -188,6 +188,18 @@ vi.mock(
() => stubModule
)
vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
vi.mock(
'@/renderer/extensions/onboardingTour/GettingStartedScreen.vue',
() => stubModule
)
vi.mock(
'@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue',
() => stubModule
)
vi.mock(
'@/renderer/extensions/onboardingTour/OnboardingTourNudge.vue',
() => stubModule
)
vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
vi.mock('@/components/MenuHamburger.vue', () => stubModule)
vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)

View File

@@ -21,6 +21,9 @@
</div>
<GlobalToast />
<GettingStartedScreen />
<OnboardingTourOverlay />
<OnboardingTourNudge />
<InviteAcceptedToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
@@ -50,6 +53,9 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import GettingStartedScreen from '@/renderer/extensions/onboardingTour/GettingStartedScreen.vue'
import OnboardingTourNudge from '@/renderer/extensions/onboardingTour/OnboardingTourNudge.vue'
import OnboardingTourOverlay from '@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue'
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'