Compare commits

...

47 Commits

Author SHA1 Message Date
Maanil Verma
e9caa313d4 test(onboarding): fix the coachmark drift-guard's assets-tab selector
getByRole('button', { name: 'Media Assets' }) never matched a real
button: the assets tab button is labelled "Assets" (its tooltip), while
"Media Assets" is only the panel header span (no button role). Open the
tab via the assetsTab page object, as appMode.spec already does.
2026-07-17 12:35:19 +05:30
Maanil Verma
62bb4d4346 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks 2026-07-17 11:41:51 +05:30
AustinMroz
13fd07201c Merge branch 'main' into pysssss/product-coachmarks 2026-07-13 12:34:39 -07:00
pythongosssss
62806d5ebd Merge branch 'main' into pysssss/product-coachmarks 2026-07-09 12:32:53 +01:00
pythongosssss
dff76dbc9d make button grow, match design 2026-07-08 03:38:48 -07:00
pythongosssss
66b9f329ce update wording 2026-07-08 03:22:56 -07:00
pythongosssss
cb1be63fb6 decrease padding 2026-07-08 03:17:09 -07:00
pythongosssss
3f5b71a472 refactor: use reka FocusScope for coachmark focus trapping
- Replace the custom two-subtree focus trap with reka FocusScope; the
  spotlight overlay is fully modal so a single-subtree trap suffices
- Focus the primary action via a template ref once it's actionable, keyed
  off waitingForTarget, and keep a global Escape-to-skip
- Add an e2e test asserting focus lands on the primary and Tab stays trapped
2026-07-07 07:24:49 -07:00
pythongosssss
7df585e606 flush -> nextTick 2026-07-06 12:47:08 -07:00
pythongosssss
f1f7c53fbe feat: rework coachmark assets step and add step-back navigation
- Auto-open the assets sidebar tab on the assets step instead of
  requiring a click on the Assets button (drops advanceOnTargetClick,
  skipIfMounted, the click-through blocker, and the idle pulse)
- Make the tour overlay fully modal and add a Back button to steps
- Cache the top-bar inset instead of reading it every animation frame
- Reset waitingForTarget when a deferred step aborts
- Dedupe the assets-panel coachmark binding in LinearView
2026-07-06 11:33:43 -07:00
pythongosssss
25ce1ebbe4 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks
# Conflicts:
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
2026-07-06 08:15:27 -07:00
pythongosssss
97308500e9 fix: keep the coachmark tour alive when outputs clear
- Split triggers into holds/autoOpen: only leaving app mode or the
  desktop layout dismisses a running tour, not losing outputs
- Trap focus in the spotlight target only on click-to-advance steps,
  matching the pointer blocker
- Skip non-laid-out elements in the focus cycle
- Drop unused CoachId[] support
- Recompress the landing image
2026-07-06 06:48:28 -07:00
pythongosssss
ccb3cd428b fix: restore the coachmark landing's md max-width
Removing md:max-w-[800px] let the DialogContent variant's sm:max-w-xl
(576px) cap the 800px landing card; tailwind-merge does not dedupe
max-w across breakpoint modifiers.
2026-07-06 05:37:37 -07:00
pythongosssss
9a71b54d05 refactor: move the coachmark tour state machine into a Pinia store
- Convert useCoachmarkTour to onboardingTourStore; replace the
  coachmarkController event hook with a direct store.replayTour() action
- End an active tour (without the seen-flag) when its trigger stops
  holding, e.g. leaving app mode mid-tour
- Toast an error and report skip_reason telemetry (user / target_timeout /
  trigger_lost) when a tour is skipped
- Disable primary buttons while waiting on a deferred target; rename
  suspendFocusGuard to waitingForTarget
- Rename useFocusTrap to useCoachmarkFocusTrap; while suspended, leave
  Tab to the mounting UI but keep the Escape bail-out
- Only auto-open tours on desktop-width (md+) layouts
- Park the registry's rAF poll while no candidate element is registered,
  resuming reactively on registration
- Extract TOUR_SEEN_SETTING and use COACH_IDS constants over string
  literals; move scrim/ring/pulse styles to design-system tokens
- Port tour tests to the store, cover Done in the e2e spec
2026-07-06 03:58:58 -07:00
pythongosssss
61756e977b fix missing modal backdrop 2026-07-03 12:50:35 -07:00
pythongosssss
7630cc9314 fix: use the generic Skip label on the coachmark tour landing
- drop the landing's "Skip for now" override so it falls back to "Skip"
- landing skip already ends the tour and marks it seen (no behavior change)
- unit tests build i18n from the real en locale instead of inline messages
- e2e covers the real auto-open path via a pre-seeded empty seen setting
- trim redundant test comments
2026-07-03 10:52:02 -07:00
pythongosssss
bb39a51d46 fix: narrow the coachmark i18n exemption to derived step keys
- only onboardingCoachmarks.<tour>.<step>.* is dynamically built;
  top-level keys (stepLabel, next, done, skip) stay checked
2026-07-02 06:15:25 -07:00
pythongosssss
76abe8eb3f Merge branch 'main' into pysssss/product-coachmarks 2026-07-02 13:59:01 +01:00
pythongosssss
9bcfda88f6 refactor: reconcile v-coachmark from the element's own data-coach-id
- a single sync() replaces the per-hook register/unregister logic;
  mounted/updated/unmounted all reconcile desired vs current id
- drops the oldValue bookkeeping and its explanatory comment
2026-07-02 05:54:52 -07:00
pythongosssss
e7cdfc8c35 chore: ignore derived coachmark keys in the unused-i18n check
- tour step keys are built as onboardingCoachmarks.<tour>.<name>.*, so
  the literal-key scan can't see them
2026-07-02 05:47:10 -07:00
pythongosssss
e97746fd16 refactor: address coachmark tour PR feedback
- derive step translation keys from a step `name`
  (onboardingCoachmarks.<tour>.<name>.*), with te()-checked
  primary/skip label overrides falling back to Next/Done/Skip
- move all tour i18n into the engine; TourOverlay and TourSpotlight
  now receive translated title/body strings
- replace the landing's required open model with a skip emit and
  drop the landingOpen writable computed
- simplify TourOverlay.test.ts mocks with fromPartial
- replace SCRIM_COLOR with Tailwind classes (bg-black/60,
  spotlight spread shadow)
- narrow the app-run-button anchor so the spotlight excludes the
  run error warning
- key the e2e tour fixture's replay button by tour name
- drop the tour spec's template loading; the test server's default
  workflow already populates the graph (locally: pnpm dev:test)
- prune comments that restated code
2026-07-02 05:39:08 -07:00
pythongosssss
549200a76c fix: address coachmark tour stacking, telemetry, and fixture review notes
- clear the spotlight's previous ZIndex entry before re-raising, so step
  changes stop leaking entries into the shared modal stacking sequence
- report tour telemetry as the user-visible numbering: 1-based step_number
  within counted spotlight steps, omitted for the landing
- seed the e2e seen-tours setting from TOURS so future tours can't
  auto-open under unrelated suites
2026-07-02 01:31:08 -07:00
pythongosssss
96c2ae1182 feat: add landing image to the app-mode tour
- Show public/assets/images/app-mode-landing.png on the welcome landing's
  left panel
2026-07-01 10:19:44 -07:00
pythongosssss
2312b213ce refactor: address coachmark tour review feedback
- Drop the ?coach= query param: remove the forced-entry/replay-any override
  and delayed force-start; tours now start only via auto-open or an explicit
  request. E2E replays via the in-app help button after entering app mode
- Flatten coachmarkController to plain requestTour/onTourRequested exports
  instead of a useCoachmarkController composable
- Derive the top-bar safe inset from the --comfy-topbar-height token plus
  CARD_GAP instead of hardcoding 56
- Remove the landing Start button's fixed width
- Document the real cause of the landing Escape workaround (global keybinding
  preventDefaults Escape before Reka's DismissableLayer dismisses)
- Trim verbose comments in coachmarkRegistry and TourOverlay tests
2026-07-01 10:07:51 -07:00
pythongosssss
ce8b107322 fix: correct coachmark tour skip telemetry and one-shot forcing
- Report the timed-out deferred step in skip telemetry by advancing the
  step index before ending the tour, instead of logging the prior step
- Make the ?coach= override one-shot: named paths force-start only via the
  delayed timer (respecting START_DELAY_MS) and re-entry honors the
  seen-flag again, rather than bypassing it all session
- Resolve waitForTarget to false immediately when the signal is already
  aborted, so it can't hang until timeout
- Reuse the cached dialog locator in Tour.cardForStep
2026-07-01 09:14:15 -07:00
pythongosssss
831813a9db fix: stop coachmark spotlight polling once its target settles
- Drive Floating UI autoUpdate manually so animationFrame polling runs
  only while a deferred target is still moving, then fall back to
  scroll/resize listeners instead of polling every frame for the whole step
- Set aria-modal to false on interaction steps where the user must click
  outside the card
- Ignore unrecognized ?coach= values; keep `any` as the replay keyword
- Align the spotlight scrim with the landing backdrop (0.62 -> 0.6) to
  avoid a dim shift on landing -> spotlight
- Export COACH_IDS and import it in the drift guard instead of a hardcoded
  in-sync list
- Clarify why the landing needs an explicit Escape listener (Reka's
  DismissableLayer doesn't fire update:open here)
- Test that the started telemetry event omits step_index/coach_id while
  per-step events include them

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:43:19 -07:00
pythongosssss
64d10da9d7 refactor: place coachmark cards with Floating UI
- Replace the hand-rolled card-placement math (resolvePlacement /
  cardCorner / clampCardPosition) and the manual target tracking (scroll
  listener + useElementBounding + rAF settle loop) with @floating-ui/vue
  (offset/flip/shift + autoUpdate); promote it from a transitive to a
  direct dependency.
- Keep vertically-centred (leftCenter) cards on-screen with
  shift({ crossAxis: true }), and centre the card when its target hasn't
  laid out, so a step never renders off-screen or invisible.
- Make targetMounted/waitForTarget layout-aware (poll per frame) so a
  deferred target that registers before it sizes resolves only once
  measurable, matching what the spotlight actually displays.
- Shrink the spotlight glow (pad 8->4) so it no longer spills onto an
  adjacent control the user might click.
- Label the spotlight dialog with aria-labelledby pointing at its heading
  instead of a duplicated aria-label.
- Drop the unused isActive option from useFocusTrap.
- Add an e2e guard that walks every app-mode spotlight step and asserts
  each card sits fully within the viewport; add unit coverage for
  last-step Skip hiding, modal z-index reclaim, and all four onboarding
  telemetry stages.
2026-07-01 06:20:03 -07:00
pythongosssss
3f84d4f5f2 test: cover forced tour start, button labels, and spotlight escape
Raise useCoachmarkTour.ts to full line coverage and TourSpotlight.vue escape handling with behavioral tests.
2026-07-01 03:45:44 -07:00
pythongosssss
5383e23d24 Merge origin/main into pysssss/product-coachmarks
Conflict in LinearControls.vue: main refactored the run-button test id into a
named constant (linearRunButtonTestId, same 'linear-run-button' value), while
this branch added the v-coachmark anchor and a static test id to the same
sections. Kept the coachmark directive and adopted main's :data-testid binding,
dropping the now-redundant static id.
2026-07-01 02:57:44 -07:00
pythongosssss
9b8dd27f3d refactor: gate coachmark tour listeners behind an active-step component
Extract TourSpotlight, rendered only while a spotlight step is shown, so the
geometry/scroll, focus-trap and click-to-advance listeners (plus z-index and the
stall pulse) register for the active step rather than the whole graph-view
session. useCoachmarkTour slims to the state machine; the registry-query helpers
(targetMounted/waitForTarget) move to coachmarkRegistry and useCoachmarkTarget
becomes geometry-only and self-measuring.

Also from review feedback:
- read the ?coach= force in setup, before the immediate auto-open watcher, so an
  already-populated app no longer drops a ?coach=any replay
- harden isEntryPath against prototype keys (Object.hasOwn)
- decouple each tour's auto-open condition into useTourTriggers, keeping the
  engine tour-agnostic
2026-06-30 14:00:43 -07:00
pythongosssss
a818b7eee8 test: open the template browser in the coachmark drift guard
Trimming the graph-mode anchors earlier removed the templates-button
click that loadTemplate relied on to open the browser; open it via the
Comfy.BrowseTemplates command instead.
2026-06-30 12:48:49 -07:00
pythongosssss
87d0a110cd fix: close the coachmark landing on Escape explicitly
Reka's built-in Escape dismissal proved unreliable for this dialog in
e2e (Skip, which sets open=false directly, works); close via the same
model path on Escape so the welcome landing reliably dismisses.
2026-06-30 12:41:24 -07:00
pythongosssss
b65da23915 refactor: slim coachmark target tracking and spotlight chrome
- Track the spotlight target with VueUse useElementBounding instead of a
  hand-rolled measure/RAF/ResizeObserver loop; keep a capture-phase scroll
  listener so it still follows targets inside scrollable panels
- Fold the spotlight ring into the dim element (CSS outline + box-shadow),
  dropping the separate SVG; the idle pulse now animates outline-color
- Remove the unused CoachStep `elevated` field and its plumbing
2026-06-30 12:07:22 -07:00
pythongosssss
07356e3253 tidy 2026-06-30 11:19:58 -07:00
pythongosssss
51182127f3 feat: auto-open app-mode tour for populated apps
- Open the app-mode tour when entering an app with linear controls
  visible (mode === 'app' && hasOutputs), including when the overlay
  mounts into one already (immediate watch)
- Respect the seen-flag, so it won't reopen once completed or skipped;
  skip the empty/welcome state and arrange (builder) mode
- Move test target DOM cleanup into afterEach so appended nodes are
  removed even if a test fails early
2026-06-30 10:49:42 -07:00
pythongosssss
fdfa9882b1 feat: make app-mode tour explicit-only and refine assets step
- Open the tour only via the help button or ?coach= param (no auto-open
  when entering app mode)
- Drop the loadTemplate step tech and the demo card image — the tour runs
  on the user's existing app
- Skip the assets-button step at tour start when the assets panel is
  already open; step indicator counts 4 instead of 5
2026-06-30 08:27:53 -07:00
pythongosssss
88cd848245 fix test 2026-06-30 04:39:29 -07:00
pythongosssss
5dbb560ef0 refactor + tidy 2026-06-30 04:35:24 -07:00
pythongosssss
f973626ebc improve test coverage 2026-06-30 03:21:59 -07:00
pythongosssss
e9729ca272 drop blankCanvas tour 2026-06-30 02:44:00 -07:00
pythongosssss
c51f963ef2 trim comments 2026-06-30 02:20:03 -07:00
pythongosssss
5e23f76642 fix: address coachmark tour review feedback
- onboardingTours: use 'auto' placement for assets panel so the card follows the sidebar side
- TourOverlay: clamp no-target card left to the viewport margin so it never goes off-screen
- useCoachmarkTour: catch onPrimary action failures, surface a toast, and only advance on success
- useCoachmarkTour: claim the single-instance guard synchronously so a duplicate mount stays inert
- useFocusTrap: send Shift+Tab from outside the trap to the last item instead of skipping it
- telemetry: drop dead run-button imports left over from the rebase (superseded by getRunButtonTelemetryProperties)
2026-06-30 02:02:27 -07:00
pythongosssss
f642384674 change dash stroke to solid 2026-06-30 02:02:27 -07:00
GitHub Action
f80deb9655 [automated] Apply ESLint and Oxfmt fixes 2026-06-29 21:57:11 +00:00
pythongosssss
75af0430fc - change dialog to use standard component
- move tour trigger to linear controls section
2026-06-29 14:48:18 -07:00
pythongosssss
cc74e1dc65 add assets step to app mode tour 2026-06-29 14:48:18 -07:00
pythongosssss
989773995a feat: add product coachmark onboarding tours
- add blank canvas (demo) and app mode (wip) tours
- overlay with target highlight, landing card, step state handling, step card
- v-coachmark directive updating registry for mount/unmount
- frame settling watcher for animated targets (dialogs)
- focus trap for the target plus the coachmark element
- add telemetry for each step
2026-06-29 14:48:18 -07:00
45 changed files with 2959 additions and 69 deletions

View File

@@ -4,6 +4,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { TOURS, TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
@@ -542,6 +543,8 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
// An auto-opened tour's blocker would break unrelated tests.
[TOUR_SEEN_SETTING]: Object.keys(TOURS),
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
// Disable toast warning about version compatibility, as they may or

View File

@@ -0,0 +1,77 @@
import type { Locator, Page } from '@playwright/test'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
export type CoachTour = 'appMode'
/** Accessible name of each tour's in-app replay (help) button. */
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
appMode: 'Take a tour of App Mode'
}
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
export class OnboardingCoachmarks {
public readonly landing: Locator
public readonly landingStartButton: Locator
public readonly landingSkipButton: Locator
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
public readonly card: Locator
public readonly cardNextButton: Locator
public readonly cardDoneButton: Locator
constructor(public readonly page: Page) {
this.landing = page.getByTestId('coach-landing')
this.landingStartButton = this.landing.getByRole('button', {
name: 'Start tutorial'
})
this.landingSkipButton = this.landing.getByRole('button', {
name: 'Skip',
exact: true
})
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
this.cardDoneButton = this.card.getByRole('button', { name: 'Done' })
}
/** The tour's in-app help button, which replays it past the seen-flag. */
replayButton(tour: CoachTour): Locator {
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
}
/** The spotlight card while it is showing the given step number. */
cardForStep(step: number): Locator {
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
}
/**
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
* set again) and clicks the tour's replay button, which must be mounted.
*/
async startTour(tour: CoachTour) {
await this.clearSeen()
await this.replayButton(tour).click()
}
private async clearSeen() {
await this.page.evaluate(
async (key) => window.app!.extensionManager.setting.set(key, []),
TOUR_SEEN_SETTING
)
}
/** An element a tour points at, by its `data-coach-id` anchor. */
coachAnchor(id: string): Locator {
return this.page.locator(`[data-coach-id="${id}"]`)
}
async seen(tour: CoachTour): Promise<boolean> {
const seen = await this.page.evaluate(
async (key) =>
(await window.app!.extensionManager.setting.get(key)) as
| string[]
| undefined,
TOUR_SEEN_SETTING
)
return !!seen?.includes(tour)
}
}

View File

@@ -0,0 +1,11 @@
import { test as base } from '@playwright/test'
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
export const onboardingFixture = base.extend<{
onboarding: OnboardingCoachmarks
}>({
onboarding: async ({ page }, use) => {
await use(new OnboardingCoachmarks(page))
}
})

View File

@@ -0,0 +1,136 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
import {
COACH_IDS,
TOUR_SEEN_SETTING
} from '@/platform/onboarding/onboardingTours'
const test = mergeTests(comfyPageFixture, onboardingFixture)
// Relies on the test server's default workflow (locally: pnpm dev:test).
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
test.describe('app-mode tour', () => {
// With no tour pre-seeded as seen, entering the app auto-opens it.
test.use({
initialSettings: { [TOUR_SEEN_SETTING]: [] }
})
test('auto-opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landing.getByRole('heading')).toHaveText(
'Welcome to Apps'
)
await expect(coach.landingStartButton).toBeFocused()
await coach.landingSkipButton.click()
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
test('Escape dismisses the welcome landing and marks it seen', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landingStartButton).toBeFocused()
await comfyPage.page.keyboard.press('Escape')
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
test.describe('coach anchors', () => {
test('every registry id resolves to an element (drift guard)', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.appMode.enterAppModeWithInputs([])
// The assets panel only mounts once the assets sidebar tab is open.
for (const id of Object.values(COACH_IDS).filter(
(id) => id !== COACH_IDS.assetsPanel
)) {
await expect(coach.coachAnchor(id)).toBeVisible()
}
await comfyPage.menu.assetsTab.tabButton.click()
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
})
})
test.describe('spotlight focus', () => {
test('focuses the primary action, traps Tab in the card, and re-focuses per step', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
const step1 = coach.cardForStep(1)
await expect(step1).toBeVisible()
// FocusScope's mount-auto-focus is suppressed so focus lands on the
// primary action rather than the first focusable (Skip).
await expect(coach.cardNextButton).toBeFocused()
// Tab more times than the card has controls; the looped trap must keep
// focus inside the card, never escaping to the app behind the overlay.
for (let i = 0; i < 4; i++) {
await comfyPage.page.keyboard.press('Tab')
await expect(step1.locator(':focus')).toBeVisible()
}
await comfyPage.page.keyboard.press('Shift+Tab')
await expect(step1.locator(':focus')).toBeVisible()
// Advancing to the next step moves focus onto its primary action.
await coach.cardNextButton.click()
await expect(coach.cardForStep(2)).toBeVisible()
await expect(coach.cardNextButton).toBeFocused()
})
})
test.describe('spotlight placement', () => {
test('every spotlight card stays fully within the viewport and Done completes the tour', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
// Read settled placements, not a transient mid-animation frame.
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
for (const step of [1, 2, 3]) {
const card = coach.cardForStep(step)
await expect(card).toBeVisible()
await expect(card).toBeInViewport({ ratio: 1 })
await coach.cardNextButton.click()
}
// The final assets step auto-opens the assets panel — no target click.
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
await coach.cardDoneButton.click()
await expect(coach.card).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
})

View File

@@ -74,6 +74,7 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@floating-ui/vue": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

View File

@@ -102,6 +102,10 @@
--color-alpha-magenta-700-60: #6a246a99;
--color-alpha-magenta-300-60: #ceaac999;
/* Onboarding coachmark overlay (always renders over a dark scrim) */
--color-coach-scrim: rgb(0 0 0 / 0.6);
--color-coach-ring: #fff;
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);

6
pnpm-lock.yaml generated
View File

@@ -30,6 +30,9 @@ catalogs:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
@@ -471,6 +474,9 @@ importers:
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@floating-ui/vue':
specifier: 'catalog:'
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0

View File

@@ -18,6 +18,7 @@ catalog:
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@floating-ui/vue': ^1.1.11
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@@ -15,6 +15,7 @@ const IGNORE_PATTERNS = [
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions

View File

@@ -1,6 +1,10 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
export const MODAL_Z_KEY = 'modal'
export const MODAL_Z_BASE = 1700
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
},
beforeUnmount(el) {
ZIndex.clear(el)

View File

@@ -4582,5 +4582,37 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"onboardingCoachmarks": {
"stepLabel": "Step {current} of {total}",
"skip": "Skip",
"next": "Next",
"back": "Back",
"done": "Done",
"loadError": "Something went wrong showing this tour",
"appMode": {
"replay": "Take a tour of App Mode",
"landing": {
"title": "Welcome to Apps",
"body": "A quick tour of the essentials, in about a minute. We'll show you where to add inputs, run your app, and find your results.",
"primary": "Start tutorial"
},
"inputs": {
"title": "Add your inputs",
"body": "Add what you want to work with. Your inputs are what the app turns into results."
},
"run": {
"title": "Run your app",
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
},
"outputs": {
"title": "Get your results",
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
},
"assets": {
"title": "Find all your assets",
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
}
}
}
}

View File

@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import CoachmarkCard from './CoachmarkCard.vue'
afterEach(cleanup)
describe('CoachmarkCard', () => {
it('renders the title, message and subtitle', () => {
render(CoachmarkCard, {
props: {
title: 'This is your canvas',
message: 'Scroll to zoom.',
subtitle: 'Step 1 of 3'
}
})
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
})
it('applies titleId to the heading for aria-labelledby wiring', () => {
render(CoachmarkCard, {
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
})
expect(screen.getByRole('heading').id).toBe('title-1')
})
it('applies messageId to the message for aria-describedby wiring', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
})
expect(screen.getByText('Body copy').id).toBe('desc-1')
})
it('omits the subtitle when not provided', () => {
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
expect(screen.queryByText('Step 1 of 3')).toBeNull()
})
it('renders the image when an image src is given', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M', image: '/foo.png' }
})
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
})
it('renders an image slot in place of the default image', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
})
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
'src',
'/slot.png'
)
})
it('renders the actions slot', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { actions: () => h('button', 'Next') }
})
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
})

View File

@@ -0,0 +1,50 @@
<template>
<div
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
>
<div
v-if="image || $slots.image"
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
>
<slot name="image">
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
</slot>
</div>
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
<div class="flex flex-col items-start gap-2 self-stretch">
<p
v-if="subtitle"
:id="subtitleId"
class="m-0 text-xs/normal text-base-foreground"
>
{{ subtitle }}
</p>
<h3
:id="titleId"
class="m-0 text-base/normal font-semibold text-base-foreground"
>
{{ title }}
</h3>
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
{{ message }}
</p>
</div>
<div v-if="$slots.actions" class="flex w-full items-center gap-3">
<slot name="actions" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
defineProps<{
title: string
titleId?: string
message: string
subtitle?: string
subtitleId?: string
image?: string
messageId?: string
}>()
</script>

View File

@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CoachmarkLanding from './CoachmarkLanding.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderLanding() {
return render(CoachmarkLanding, {
props: {
title: 'Welcome to Apps',
message: 'A quick tour of the essentials.',
primaryLabel: 'Start tutorial',
skipLabel: 'Skip',
waitingForTarget: false
},
global: { plugins: [i18n] }
})
}
describe('CoachmarkLanding', () => {
afterEach(cleanup)
it('renders a modal backdrop behind the landing card', async () => {
renderLanding()
expect(await screen.findByTestId('coach-landing-overlay')).toBeTruthy()
})
it('renders the title and message', async () => {
renderLanding()
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
})
it('emits start when the primary action is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(emitted().start).toHaveLength(1)
})
it('emits skip when Skip is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await screen.findByText('Welcome to Apps')
await user.keyboard('{Escape}')
// The explicit listener and Reka's own dismiss may both fire here.
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -0,0 +1,120 @@
<template>
<!-- The Dialog wrapper boolean-casts an absent `modal` to false, which
suppresses the DialogOverlay backdrop; it must be passed explicitly. -->
<Dialog :open="true" modal @update:open="(value) => !value && emit('skip')">
<DialogPortal>
<DialogOverlay
v-reka-z-index
data-testid="coach-landing-overlay"
class="bg-coach-scrim"
/>
<DialogContent
v-reka-z-index
data-testid="coach-landing"
class="w-[800px] max-w-[calc(100vw-2.5rem)] overflow-hidden rounded-2xl border-border-default bg-secondary-background p-0 shadow-[0_24px_80px_rgba(0,0,0,0.85)] md:h-fit md:min-h-100 md:max-w-[800px] md:flex-row"
@pointer-down-outside.prevent
@open-auto-focus="onOpenAutoFocus"
>
<DialogClose as-child>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
class="absolute top-3 right-3 z-20"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
<div
class="flex aspect-video items-center justify-center bg-base-background md:aspect-auto md:w-1/2"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover"
/>
</div>
<div
class="flex flex-col items-start justify-between self-stretch px-15 pt-15 pb-10 md:w-1/2"
>
<div class="flex flex-col gap-2.5">
<DialogTitle
class="m-0 text-[28px] leading-normal font-medium text-base-foreground"
>
{{ title }}
</DialogTitle>
<DialogDescription
class="flex flex-col items-start gap-2 self-stretch text-base/5 text-muted-foreground"
>
{{ message }}
</DialogDescription>
</div>
<div class="flex w-full items-center justify-end gap-2">
<Button variant="secondary" size="lg" @click="emit('skip')">
{{ skipLabel }}
</Button>
<Button
ref="startButtonRef"
variant="inverted"
size="lg"
class="grow"
:disabled="waitingForTarget"
@click="emit('start')"
>
{{ primaryLabel }}
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Button from '@/components/ui/button/Button.vue'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
const { title, message, image, primaryLabel, skipLabel, waitingForTarget } =
defineProps<{
title: string
message: string
image?: string
primaryLabel: string
skipLabel: string
waitingForTarget: boolean
}>()
const emit = defineEmits<{
start: []
skip: []
}>()
const { t } = useI18n()
// The global keybinding handler preventDefaults Escape before Reka's
// DismissableLayer sees it, so Reka skips its own dismiss; do it explicitly.
useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') emit('skip')
})
const startButtonRef = useTemplateRef('startButtonRef')
// Land focus on the primary action, not the close button Reka would pick.
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = startButtonRef.value?.$el as HTMLButtonElement | undefined
el?.focus()
}
</script>

View File

@@ -0,0 +1,126 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import TourOverlay from './TourOverlay.vue'
import type { CoachStep } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
function makeTourState() {
return {
step: ref<CoachStep | null>(null),
title: ref('Canvas title'),
body: ref('Canvas body'),
isLast: ref(false),
primaryLabel: ref('Next'),
skipLabel: ref('Skip'),
countedStepIdx: ref(0),
countedStepsTotal: ref(0),
waitingForTarget: ref(false),
next: vi.fn(),
skip: vi.fn()
}
}
// Stubbed so the suite covers only TourOverlay's branching and intent wiring.
vi.mock('./TourSpotlight.vue', () => ({
default: defineComponent({
emits: ['advance', 'skip'],
setup(_, { emit }) {
return () =>
h('div', { 'data-testid': 'spotlight' }, [
h('button', { onClick: () => emit('advance') }, 'advance'),
h('button', { onClick: () => emit('skip') }, 'skip')
])
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
let s: ReturnType<typeof makeTourState>
const spotlightStep: CoachStep = {
name: 'run',
placement: 'right'
}
function landingStep(): CoachStep {
return { name: 'landing', placement: 'center', landing: true }
}
function renderOverlay() {
return render(TourOverlay, { global: { plugins: [i18n] } })
}
describe('TourOverlay', () => {
beforeEach(() => {
s = makeTourState()
vi.mocked(useOnboardingTourStore).mockReturnValue(fromPartial(reactive(s)))
})
afterEach(cleanup)
it('renders nothing when no tour step is active', () => {
renderOverlay()
expect(screen.queryByTestId('spotlight')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('renders the spotlight for a non-landing step and wires its intents', async () => {
const user = userEvent.setup()
s.step.value = spotlightStep
renderOverlay()
expect(screen.getByTestId('spotlight')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'advance' }))
expect(s.next).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
it('renders the landing step and starts the tour on its primary action', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
renderOverlay()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(s.next).toHaveBeenCalledOnce()
})
it('disables the landing primary action while waiting for a deferred target', async () => {
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
s.waitingForTarget.value = true
renderOverlay()
expect(
await screen.findByRole('button', { name: 'Start tutorial' })
).toBeDisabled()
})
it('ends the tour when the landing is dismissed', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,38 @@
<template>
<CoachmarkLanding
v-if="tour.step?.landing"
:title="tour.title"
:message="tour.body"
:image="tour.step.image"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:waiting-for-target="tour.waitingForTarget"
@start="tour.next"
@skip="tour.skip"
/>
<TourSpotlight
v-else-if="tour.step"
:step="tour.step"
:title="tour.title"
:body="tour.body"
:is-last="tour.isLast"
:can-go-back="tour.canGoBack"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:back-label="tour.backLabel"
:counted-step-idx="tour.countedStepIdx"
:counted-steps-total="tour.countedStepsTotal"
:waiting-for-target="tour.waitingForTarget"
@advance="tour.next"
@back="tour.back"
@skip="tour.skip"
/>
</template>
<script setup lang="ts">
import CoachmarkLanding from './CoachmarkLanding.vue'
import TourSpotlight from './TourSpotlight.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
const tour = useOnboardingTourStore()
</script>

View File

@@ -0,0 +1,147 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { clearCoachmarks } from './coachmarkRegistry'
import TourSpotlight from './TourSpotlight.vue'
import type { CoachStep } from './onboardingTours'
vi.mock('@primeuix/utils/zindex', () => ({
ZIndex: { set: vi.fn(), clear: vi.fn() }
}))
import { ZIndex } from '@primeuix/utils/zindex'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function spotlightStep(overrides: Partial<CoachStep> = {}): CoachStep {
return { name: 'run', placement: 'right', ...overrides }
}
const baseProps = {
title: 'Run your app',
body: 'Press to run',
isLast: false,
canGoBack: false,
primaryLabel: 'Next',
skipLabel: 'Skip',
backLabel: 'Back',
countedStepIdx: 0,
countedStepsTotal: 1,
waitingForTarget: false
}
function renderSpotlight(
props: Partial<ComponentProps<typeof TourSpotlight>> = {}
) {
return render(TourSpotlight, {
props: { step: spotlightStep(), ...baseProps, ...props },
global: { plugins: [i18n] }
})
}
describe('TourSpotlight', () => {
afterEach(() => {
cleanup()
clearCoachmarks()
document.body.replaceChildren()
vi.useRealTimers()
})
it('renders the spotlight and card for a step', () => {
renderSpotlight()
expect(screen.getByTestId('coach-spotlight')).toBeTruthy()
expect(screen.getByRole('dialog', { name: 'Run your app' })).toBeTruthy()
expect(screen.getByText('Press to run')).toBeTruthy()
expect(screen.getByText('Step 1 of 1')).toBeTruthy()
})
it('hides the Skip button on the last step', () => {
renderSpotlight({ isLast: true })
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
it('shows Back and emits back when there is a previous step', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight({ canGoBack: true })
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(emitted().back).toHaveLength(1)
})
it('hides Back on the first step', () => {
renderSpotlight({ canGoBack: false })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('claims the modal stack on mount and releases it on unmount', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { unmount } = renderSpotlight()
await nextTick()
await nextTick()
expect(ZIndex.set).toHaveBeenCalled()
unmount()
expect(ZIndex.clear).toHaveBeenCalled()
})
it('re-claims the modal stack per step without leaking entries', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { rerender, unmount } = renderSpotlight()
await nextTick()
await nextTick()
await rerender({ step: spotlightStep({ placement: 'left' }) })
await nextTick()
await nextTick()
unmount()
// Sets must pair with clears or entries leak; the +1 is the unmount clear.
expect(vi.mocked(ZIndex.clear).mock.calls.length).toBe(
vi.mocked(ZIndex.set).mock.calls.length + 1
)
expect(ZIndex.set).toHaveBeenCalled()
})
it('emits advance on the primary button and skip on the secondary', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(emitted().advance).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.keyboard('{Escape}')
expect(emitted().skip).toHaveLength(1)
})
it('disables the primary button while waiting for a deferred target', () => {
renderSpotlight({ waitingForTarget: true })
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
})
it('hides the spotlight and dims via the blocker for a step with no target', () => {
renderSpotlight({ step: spotlightStep({ placement: 'center' }) })
expect(screen.getByTestId('coach-spotlight').style.opacity).toBe('0')
})
})

View File

@@ -0,0 +1,239 @@
<template>
<div ref="overlayRef" class="pointer-events-none fixed inset-0">
<div
:class="
cn(
'pointer-events-auto absolute inset-0',
!targetRect && 'bg-coach-scrim'
)
"
/>
<div
aria-hidden="true"
data-testid="coach-spotlight"
class="pointer-events-none absolute rounded-xl shadow-[0_0_0_9999px_var(--color-coach-scrim)] outline-2 outline-coach-ring motion-safe:transition-[left,top,width,height,opacity] motion-safe:duration-300"
:style="spotlightStyle"
/>
<FocusScope
as-child
:trapped="!waitingForTarget"
loop
@mount-auto-focus.prevent
>
<div
ref="cardRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="`${subtitleId} ${bodyId}`"
class="pointer-events-auto absolute max-h-[calc(100vh-var(--comfy-topbar-height)-2rem)] overflow-y-auto motion-safe:transition-[left,top] motion-safe:duration-300"
:style="cardStyle"
>
<CoachmarkCard
:subtitle="
t('onboardingCoachmarks.stepLabel', {
current: countedStepIdx + 1,
total: countedStepsTotal
})
"
:subtitle-id="subtitleId"
:title
:title-id="titleId"
:message="body"
:message-id="bodyId"
:image="step.image"
>
<template #actions>
<Button
v-if="showSkip"
variant="textonly"
size="md"
@click="emit('skip')"
>
{{ skipLabel }}
</Button>
<div class="ml-auto flex items-center gap-3">
<Button
v-if="canGoBack"
variant="secondary"
size="md"
class="border border-solid border-border-default"
@click="emit('back')"
>
<i class="icon-[lucide--arrow-left]" />
{{ backLabel }}
</Button>
<Button
ref="primaryButton"
variant="inverted"
size="md"
:disabled="waitingForTarget"
@click="emit('advance')"
>
{{ primaryLabel }}
<i v-if="!isLast" class="icon-[lucide--arrow-right]" />
</Button>
</div>
</template>
</CoachmarkCard>
</div>
</FocusScope>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener, useWindowSize } from '@vueuse/core'
import { ZIndex } from '@primeuix/utils/zindex'
import { FocusScope } from 'reka-ui'
import {
computed,
nextTick,
onBeforeUnmount,
ref,
useId,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { MODAL_Z_BASE, MODAL_Z_KEY } from '@/components/dialog/vRekaZIndex'
import Button from '@/components/ui/button/Button.vue'
import CoachmarkCard from './CoachmarkCard.vue'
import {
CARD_WIDTH,
SPOTLIGHT_PAD,
VIEWPORT_MARGIN,
clampSpotlight,
noTargetCardLeft
} from './coachmarkLayout'
import type { CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
const {
step,
title,
body,
isLast,
canGoBack,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget
} = defineProps<{
step: CoachStep
title: string
body: string
isLast: boolean
canGoBack: boolean
primaryLabel: string
skipLabel: string
backLabel: string
countedStepIdx: number
countedStepsTotal: number
waitingForTarget: boolean
}>()
const emit = defineEmits<{
advance: []
back: []
skip: []
}>()
const { t } = useI18n()
const bodyId = useId()
const subtitleId = useId()
const titleId = useId()
const overlayRef = ref<HTMLElement | null>(null)
const cardRef = ref<HTMLElement | null>(null)
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { targetRect, targetEl, floatingStyles, isPositioned } =
useCoachmarkTarget(() => step, cardRef)
// Last step's "Done" already dismisses, so hide Skip there.
const showSkip = computed(() => !isLast)
const primaryButton =
useTemplateRef<InstanceType<typeof Button>>('primaryButton')
async function focusPrimary() {
await nextTick()
const el = primaryButton.value?.$el as HTMLElement | undefined
el?.focus()
}
useEventListener(
document,
'keydown',
(e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
emit('skip')
},
{ capture: true }
)
async function raiseOverlay() {
await nextTick()
const el = overlayRef.value
if (!el) return
// ZIndex.set pushes a fresh entry into the shared modal sequence on every
// call, so clear the previous one or per-step re-raises leak entries.
ZIndex.clear(el)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
}
watch(
() => step,
() => void raiseOverlay(),
{ immediate: true }
)
watch(
[() => step, () => waitingForTarget],
() => {
if (!waitingForTarget) void focusPrimary()
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (overlayRef.value) ZIndex.clear(overlayRef.value)
})
function viewport() {
return { width: windowWidth.value, height: windowHeight.value }
}
const spotlightStyle = computed(() => {
const r = targetRect.value
if (!r) return { opacity: '0' }
return { ...clampSpotlight(r, SPOTLIGHT_PAD, viewport()), opacity: '1' }
})
const cardStyle = computed(() => {
const width = `${CARD_WIDTH}px`
const maxWidth = `calc(100vw - ${VIEWPORT_MARGIN * 2}px)`
if (!targetEl.value) {
return {
width,
maxWidth,
left: `${noTargetCardLeft(windowWidth.value)}px`,
top: '30%'
}
}
// Hidden until Floating UI positions it, avoiding a first-frame jump.
return {
...floatingStyles.value,
width,
maxWidth,
opacity: isPositioned.value ? '1' : '0'
}
})
</script>

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CARD_GAP,
clampSpotlight,
noTargetCardLeft,
topSafeInset
} from './coachmarkLayout'
const VIEWPORT = { width: 1000, height: 800 }
describe('clampSpotlight', () => {
it('grows the target rect by the pad on every side', () => {
const r = new DOMRect(100, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toEqual({
left: '92px',
top: '92px',
width: '66px',
height: '56px'
})
})
it('clamps the near edges to the viewport inset', () => {
const r = new DOMRect(0, 0, 10, 10)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({
left: '2px',
top: '2px'
})
})
it('clamps the far edges to the viewport inset', () => {
const r = new DOMRect(990, 100, 50, 40)
const { left, width } = clampSpotlight(r, 8, VIEWPORT)
expect(left).toBe('982px')
expect(width).toBe('16px')
})
it('never produces a negative size for an off-screen target', () => {
const r = new DOMRect(2000, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({ width: '0px' })
})
})
describe('topSafeInset', () => {
afterEach(() => {
document.documentElement.style.removeProperty('--comfy-topbar-height')
})
it('converts a rem top bar height to px and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '3rem')
expect(topSafeInset()).toBe(48 + CARD_GAP)
})
it('reads a px top bar height directly and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '50px')
expect(topSafeInset()).toBe(50 + CARD_GAP)
})
it('falls back to the card gap alone when the token is unset', () => {
expect(topSafeInset()).toBe(CARD_GAP)
})
})
describe('noTargetCardLeft', () => {
it('centers the card on a wide viewport', () => {
expect(noTargetCardLeft(1000)).toBe(350)
})
it('clamps to the viewport margin when the viewport is narrower than the card', () => {
expect(noTargetCardLeft(200)).toBe(12)
})
})

View File

@@ -0,0 +1,55 @@
export interface Viewport {
width: number
height: number
}
export interface BoxStyle {
left: string
top: string
width: string
height: string
}
const SPOTLIGHT_EDGE_INSET = 2
export const CARD_WIDTH = 300
export const VIEWPORT_MARGIN = 12
export const CARD_GAP = 16
// Kept tight so the spotlight glow doesn't spill onto an adjacent clickable control.
export const SPOTLIGHT_PAD = 4
export function clampSpotlight(
r: DOMRect,
pad: number,
viewport: Viewport
): BoxStyle {
const left = Math.max(SPOTLIGHT_EDGE_INSET, r.left - pad)
const top = Math.max(SPOTLIGHT_EDGE_INSET, r.top - pad)
const right = Math.min(viewport.width - SPOTLIGHT_EDGE_INSET, r.right + pad)
const bottom = Math.min(
viewport.height - SPOTLIGHT_EDGE_INSET,
r.bottom + pad
)
return {
left: `${left}px`,
top: `${top}px`,
width: `${Math.max(0, right - left)}px`,
height: `${Math.max(0, bottom - top)}px`
}
}
export function noTargetCardLeft(viewportWidth: number): number {
return Math.max(VIEWPORT_MARGIN, (viewportWidth - CARD_WIDTH) / 2)
}
const TOP_BAR_HEIGHT_VAR = '--comfy-topbar-height'
/** The top bar's height, read from the theme token, plus the standard gap. */
export function topSafeInset(): number {
const root = document.documentElement
const raw = getComputedStyle(root).getPropertyValue(TOP_BAR_HEIGHT_VAR).trim()
const px = raw.endsWith('rem')
? parseFloat(raw) * parseFloat(getComputedStyle(root).fontSize)
: parseFloat(raw)
return (Number.isFinite(px) ? px : 0) + CARD_GAP
}

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import {
clearCoachmarks,
coachmarkElements,
registerCoachmark,
targetMounted,
unregisterCoachmark,
waitForTarget
} from './coachmarkRegistry'
/** An element with a non-zero measured rect, so it counts as laid out. */
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
return el
}
describe('coachmarkRegistry', () => {
const a = document.createElement('div')
const b = document.createElement('div')
afterEach(clearCoachmarks)
it('resolves every element registered for an id', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
expect(coachmarkElements('app-run-button')).toEqual([a, b])
})
it('keeps the remaining elements when one of several unregisters', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
unregisterCoachmark('app-run-button', a)
expect(coachmarkElements('app-run-button')).toEqual([b])
})
})
describe('targetMounted', () => {
afterEach(clearCoachmarks)
it('is true once a laid-out element is registered', () => {
expect(targetMounted('app-run-button')).toBe(false)
registerCoachmark('app-run-button', laidOut())
expect(targetMounted('app-run-button')).toBe(true)
})
it('ignores a registered target that is not laid out (e.g. hidden)', () => {
registerCoachmark('outputs', document.createElement('div'))
expect(targetMounted('outputs')).toBe(false)
})
})
describe('waitForTarget', () => {
let frames: Array<() => void>
beforeEach(() => {
frames = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frames.push(() => cb(0))
return frames.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
clearCoachmarks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Drain the poll queue; an unresolved poll reschedules, so cap the drain to
// avoid spinning when the target never lays out.
function runFrames(max = 50) {
let ran = 0
while (frames.length && ran < max) {
frames.shift()!()
ran++
}
}
it('resolves true immediately when a laid-out target is already mounted', async () => {
registerCoachmark('app-run-button', laidOut())
const signal = new AbortController().signal
await expect(waitForTarget('app-run-button', signal, 1000)).resolves.toBe(
true
)
})
it('resolves true once the target lays out before the timeout', async () => {
const signal = new AbortController().signal
const found = waitForTarget('app-run-button', signal, 1000)
registerCoachmark('app-run-button', laidOut())
runFrames()
await expect(found).resolves.toBe(true)
})
it('keeps waiting for a registered target until it lays out', async () => {
const el = document.createElement('div')
registerCoachmark('outputs', el)
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
runFrames()
await Promise.resolve()
expect(resolved).toBeUndefined()
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
runFrames()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('does not schedule rAF polling while no candidate is registered', () => {
const signal = new AbortController().signal
void waitForTarget('outputs', signal, 1000)
expect(frames).toHaveLength(0)
})
it('parks the poll when the last candidate unregisters and resumes on re-registration', async () => {
const el = document.createElement('div')
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
registerCoachmark('outputs', el)
await nextTick()
expect(frames.length).toBeGreaterThan(0)
unregisterCoachmark('outputs', el)
await nextTick()
runFrames()
expect(frames).toHaveLength(0)
registerCoachmark('outputs', laidOut())
await nextTick()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('resolves false when the target never mounts (transient failure)', async () => {
vi.useFakeTimers()
const signal = new AbortController().signal
const found = waitForTarget('outputs', signal, 1000)
await vi.advanceTimersByTimeAsync(1000)
await expect(found).resolves.toBe(false)
})
it('resolves false when aborted before the target mounts', async () => {
const controller = new AbortController()
const found = waitForTarget('outputs', controller.signal, 10000)
controller.abort()
await expect(found).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,84 @@
import { shallowReactive, watch } from 'vue'
import type { CoachId } from './onboardingTours'
const EMPTY: readonly HTMLElement[] = []
/** Laid out — a registered target that is currently visible and has a size. */
export function isLaidOut(el: HTMLElement): boolean {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0
}
// An id can map to several elements (e.g. responsive variants); consumers pick
// the first laid-out one.
const registry = shallowReactive(new Map<CoachId, readonly HTMLElement[]>())
export function registerCoachmark(id: CoachId, el: HTMLElement) {
registry.set(id, [...(registry.get(id) ?? EMPTY), el])
}
export function unregisterCoachmark(id: CoachId, el: HTMLElement) {
const next = (registry.get(id) ?? EMPTY).filter((entry) => entry !== el)
if (next.length) registry.set(id, next)
else registry.delete(id)
}
export function coachmarkElements(id: CoachId): readonly HTMLElement[] {
return registry.get(id) ?? EMPTY
}
export function targetMounted(id: CoachId): boolean {
return coachmarkElements(id).some(isLaidOut)
}
/** Resolves once a laid-out element for the id exists; false on timeout or abort. */
export function waitForTarget(
id: CoachId,
signal: AbortSignal,
timeoutMs: number
): Promise<boolean> {
if (targetMounted(id)) return Promise.resolve(true)
// An already-aborted signal never fires 'abort', so resolve up front.
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
let done = false
let frame = 0
function finish(found: boolean) {
if (done) return
done = true
stopWatch()
cancelAnimationFrame(frame)
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
resolve(found)
}
function onAbort() {
finish(false)
}
// Laid-out-ness is a layout read the registry can't observe, so it needs
// polling — but only while a candidate exists. Registration is reactive,
// so the watch (re)starts the poll instead of spinning every frame while
// the target hasn't even mounted.
function poll() {
if (targetMounted(id)) finish(true)
else if (coachmarkElements(id).length) frame = requestAnimationFrame(poll)
}
const stopWatch = watch(
() => coachmarkElements(id).length,
() => {
cancelAnimationFrame(frame)
poll()
},
{ flush: 'post' }
)
const timer = setTimeout(() => finish(false), timeoutMs)
signal.addEventListener('abort', onAbort)
poll()
})
}
/** Resets shared state between tests. */
export function clearCoachmarks() {
registry.clear()
}

View File

@@ -0,0 +1,516 @@
import type { DetachedWindowAPI } from 'happy-dom'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import type { Pinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { Ref } from 'vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { AppMode } from '@/utils/appMode'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import { TOUR_SEEN_SETTING } from './onboardingTours'
import type { CoachId } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
const settings = vi.hoisted(() => ({ store: new Map<string, unknown>() }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
settings.store.get(key) ?? (key === TOUR_SEEN_SETTING ? [] : undefined),
set: (key: string, value: unknown) => {
settings.store.set(key, value)
return Promise.resolve()
}
})
}))
const telemetry = vi.hoisted(() => ({ track: vi.fn() }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackOnboardingTour: telemetry.track })
}))
const appModeMock = vi.hoisted(
() =>
({ mode: null, hasOutputs: null }) as {
mode: Ref<AppMode> | null
hasOutputs: Ref<boolean> | null
}
)
vi.mock('@/composables/useAppMode', async () => {
const { ref: r } = await import('vue')
appModeMock.mode = r<AppMode>('graph')
return { useAppMode: () => ({ mode: appModeMock.mode }) }
})
vi.mock('@/stores/appModeStore', async () => {
const { ref: r } = await import('vue')
appModeMock.hasOutputs = r(false)
const hasOutputs = appModeMock.hasOutputs
return {
useAppModeStore: () => ({
get hasOutputs() {
return hasOutputs.value
}
})
}
})
const APP_MODE_TARGETS: CoachId[] = [
'inputs-list',
'app-run-button',
'outputs',
'assets-panel'
]
function seenTours(): string[] {
return (settings.store.get(TOUR_SEEN_SETTING) as string[] | undefined) ?? []
}
function setViewport(viewport: { width: number; height: number }) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport(viewport)
}
let pinia: Pinia | undefined
function mountStore() {
pinia = createPinia()
setActivePinia(pinia)
return useOnboardingTourStore()
}
function startedCount() {
return telemetry.track.mock.calls.filter(([stage]) => stage === 'started')
.length
}
describe('onboardingTourStore', () => {
// Removed in teardown even when a test throws before its own cleanup.
const appendedTargets: HTMLElement[] = []
afterEach(() => {
if (pinia) disposePinia(pinia)
pinia = undefined
clearCoachmarks()
appendedTargets.forEach((el) => el.remove())
appendedTargets.length = 0
settings.store.clear()
setViewport({ width: 1024, height: 768 })
if (appModeMock.mode) appModeMock.mode.value = 'graph'
if (appModeMock.hasOutputs) appModeMock.hasOutputs.value = false
telemetry.track.mockClear()
vi.useRealTimers()
})
/** Register one laid-out element for a coach id, so its step resolves at once. */
function mountTarget(id: CoachId): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100)
document.body.appendChild(el)
appendedTargets.push(el)
registerCoachmark(id, el)
return el
}
function registerAppModeTargets(
ids: CoachId[] = APP_MODE_TARGETS
): Map<CoachId, HTMLElement> {
return new Map(ids.map((id) => [id, mountTarget(id)]))
}
function enterApp(mode: AppMode, hasOutputs: boolean) {
const modeRef = appModeMock.mode
const outputsRef = appModeMock.hasOutputs
if (!modeRef || !outputsRef)
throw new Error('app mode mock not initialised')
modeRef.value = mode
outputsRef.value = hasOutputs
}
it('auto-opens when entering a populated app it has not seen', async () => {
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
})
it('auto-opens when instantiated in an already-populated app', async () => {
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(1)
})
it('does not auto-open in an empty app with no linear controls', async () => {
mountStore()
enterApp('app', false)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open in arrange (builder) mode', async () => {
mountStore()
enterApp('builder:arrange', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open on a mobile-width viewport', async () => {
setViewport({ width: 500, height: 800 })
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open a populated app once the tour has been dismissed', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('replays a seen tour when explicitly requested', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('dismisses a replayed tour without the seen-flag when the user leaves its mode', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
enterApp('app', false)
await nextTick()
store.replayTour('appMode')
await nextTick()
expect(store.step?.name).toBe('landing')
enterApp('graph', false)
await nextTick()
expect(store.step).toBeNull()
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
})
it('keeps the tour running when outputs disappear but the mode still holds', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('app', false)
await nextTick()
expect(store.step).not.toBeNull()
})
it('marks the tour seen when it ends normally', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.skip()
expect(seenTours()).toContain('appMode')
// A deliberate dismissal reports the user as the skip reason.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'user' })
})
it('skips without the seen-flag and toasts when a deferred target never appears', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
// The deferred target (inputs list) is never registered; exhaust the wait.
await vi.advanceTimersByTimeAsync(8000)
expect(seenTours()).not.toContain('appMode')
// The skipped event reports the timed-out step, not the prior landing,
// and attributes the skip to the timeout rather than the user.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({
step_number: 1,
coach_id: 'inputs-list',
skip_reason: 'target_timeout'
})
expect(useToastStore().messagesToAdd).toContainEqual(
expect.objectContaining({
severity: 'error',
detail: 'Something went wrong showing this tour'
})
)
})
it('advances once a deferred target mounts during the wait', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
expect(store.waitingForTarget).toBe(true)
mountTarget('inputs-list')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('inputs-list')
})
it('keeps the original deadline when advance is requested again mid-wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
await vi.advanceTimersByTimeAsync(5000)
store.next()
await vi.advanceTimersByTimeAsync(3000)
expect(useToastStore().messagesToAdd).toHaveLength(1)
})
it('does not toast or double-report when the user skips during a deferred wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
store.skip()
await vi.advanceTimersByTimeAsync(8000)
const skipped = telemetry.track.mock.calls.filter(
([stage]) => stage === 'skipped'
)
expect(skipped).toHaveLength(1)
expect(skipped[0]?.[1]).toMatchObject({ skip_reason: 'user' })
expect(useToastStore().messagesToAdd).toHaveLength(0)
})
it('ends an active tour without the seen-flag when its trigger stops holding', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('graph', true)
await nextTick()
expect(store.step).toBeNull()
expect(seenTours()).not.toContain('appMode')
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(2)
})
it('aborts a pending deferred wait without a toast when the trigger stops holding', async () => {
vi.useFakeTimers()
const store = mountStore()
enterApp('app', true)
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
enterApp('graph', true)
await vi.advanceTimersByTimeAsync(8000)
expect(useToastStore().messagesToAdd).toHaveLength(0)
const skipReasons = telemetry.track.mock.calls
.filter(([stage]) => stage === 'skipped')
.map(([, meta]) => meta.skip_reason)
expect(skipReasons).toEqual(['trigger_lost'])
})
it('ignores a second concurrent request while the first tour is resolving', async () => {
const store = mountStore()
store.replayTour('appMode')
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('advances through every step to completion and marks the tour seen', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// Capped so a stuck tour fails instead of hanging.
for (let i = 0; i < 12 && !seenTours().includes('appMode'); i++) {
store.next()
await nextTick()
}
expect(seenTours()).toContain('appMode')
const completed = telemetry.track.mock.calls.find(
([stage]) => stage === 'completed'
)
expect(completed).toBeTruthy()
expect(completed?.[1]).not.toHaveProperty('skip_reason')
})
it('opens the assets sidebar tab and resolves its deferred panel on the assets step', async () => {
registerAppModeTargets(
APP_MODE_TARGETS.filter((id) => id !== 'assets-panel')
)
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.countedStepsTotal).toBe(4)
// Advance landing -> inputs -> run -> outputs, then onto the assets step.
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
// The assets step defers on its panel; the tab auto-opens while it waits.
expect(store.waitingForTarget).toBe(true)
expect(useSidebarTabStore().activeSidebarTabId).toBe('assets')
mountTarget('assets-panel')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('assets-panel')
})
it('leaves an already-open assets tab open when reaching the assets step', async () => {
registerAppModeTargets()
const store = mountStore()
const sidebar = useSidebarTabStore()
sidebar.toggleSidebarTab('assets')
expect(sidebar.activeSidebarTabId).toBe('assets')
store.replayTour('appMode')
await nextTick()
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
expect(store.step?.coachId).toBe('assets-panel')
expect(sidebar.activeSidebarTabId).toBe('assets')
})
it('steps back to the previous step', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
await nextTick()
store.next()
await nextTick()
expect(store.step?.coachId).toBe('app-run-button')
expect(store.canGoBack).toBe(true)
store.back()
await nextTick()
expect(store.step?.coachId).toBe('inputs-list')
})
it('reports step index 0 while no tour is active', () => {
const store = mountStore()
expect(store.countedStepIdx).toBe(0)
})
it('labels the buttons from the step, falling back to Next then Done', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing's `primary` entry overrides only the primary label.
expect(store.step?.landing).toBe(true)
expect(store.primaryLabel).toBe('Start tutorial')
expect(store.skipLabel).toBe('Skip')
expect(store.countedStepsTotal).toBe(4)
store.next()
await nextTick()
expect(store.primaryLabel).toBe('Next')
expect(store.skipLabel).toBe('Skip')
for (let i = 0; i < 3; i++) {
store.next()
await nextTick()
}
expect(store.isLast).toBe(true)
expect(store.primaryLabel).toBe('Done')
})
it('reports the user-visible step numbering, omitting it for the landing', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing isn't numbered, so the four spotlight steps carry the count.
const started = telemetry.track.mock.calls.find(
([stage]) => stage === 'started'
)
expect(started?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
const landingShown = telemetry.track.mock.calls.find(
([stage]) => stage === 'step_shown'
)
expect(landingShown?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
store.next()
await nextTick()
const shown = telemetry.track.mock.calls
.filter(([stage]) => stage === 'step_shown')
.at(-1)
expect(shown?.[1]).toEqual({
tour: 'appMode',
step_count: 4,
step_number: 1,
coach_id: 'inputs-list'
})
})
it('advancing off the landing does not mark the tour skipped', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.step?.landing).toBe(true)
store.next()
await nextTick()
expect(store.step?.landing).toBeFalsy()
const skipped = telemetry.track.mock.calls.some(
([stage]) => stage === 'skipped'
)
expect(skipped).toBe(false)
})
})

View File

@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { t, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type {
OnboardingTourSkipReason,
OnboardingTourStage
} from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { targetMounted, waitForTarget } from './coachmarkRegistry'
import { TOURS, TOUR_SEEN_SETTING, resolveSteps } from './onboardingTours'
import type { CoachStep, EntryPath } from './onboardingTours'
import { useTourTriggers } from './useTourTriggers'
const DEFER_TIMEOUT_MS = 8000
/**
* The tour state machine: which tour starts and when, which steps run, and the
* advance/skip/complete lifecycle.
*/
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
const settingStore = useSettingStore()
const telemetry = useTelemetry()
const steps = shallowRef<CoachStep[]>([])
const stepIdx = ref(0)
const waitingForTarget = ref(false)
const activeTour = ref<EntryPath | null>(null)
let stepController: AbortController | null = null
const step = computed<CoachStep | null>(
() => steps.value[stepIdx.value] ?? null
)
const isLast = computed(() => stepIdx.value === steps.value.length - 1)
const countedSteps = computed(() => steps.value.filter((s) => !s.landing))
const countedStepsTotal = computed(() => countedSteps.value.length)
const countedStepIdx = computed(() => {
const s = step.value
return s ? countedSteps.value.indexOf(s) : 0
})
// Back navigates the numbered steps only — never into the landing.
const canGoBack = computed(() => countedStepIdx.value > 0)
function trackTour(
stage: OnboardingTourStage,
skipReason?: OnboardingTourSkipReason
) {
const tour = activeTour.value
if (!tour) return
telemetry?.trackOnboardingTour(stage, {
tour,
step_count: countedSteps.value.length,
...(stage !== 'started' &&
countedStepIdx.value >= 0 && {
step_number: countedStepIdx.value + 1,
coach_id: step.value?.coachId
}),
...(skipReason && { skip_reason: skipReason })
})
}
function stepKey(suffix: string) {
return `onboardingCoachmarks.${activeTour.value}.${step.value?.name}.${suffix}`
}
const title = computed(() => (step.value ? t(stepKey('title')) : ''))
const body = computed(() => (step.value ? t(stepKey('body')) : ''))
// A step overrides the generic button labels by declaring `primary`/`skip`
// entries under its translation keys.
const primaryLabel = computed(() => {
if (step.value && te(stepKey('primary'))) return t(stepKey('primary'))
return isLast.value
? t('onboardingCoachmarks.done')
: t('onboardingCoachmarks.next')
})
const skipLabel = computed(() =>
step.value && te(stepKey('skip'))
? t(stepKey('skip'))
: t('onboardingCoachmarks.skip')
)
const backLabel = computed(() => t('onboardingCoachmarks.back'))
async function showStep(idx: number) {
const nextStep = steps.value[idx]
if (!nextStep) return
stepController?.abort()
const controller = new AbortController()
stepController = controller
const { signal } = controller
waitingForTarget.value = false
if (nextStep.openSidebarTab) openSidebarTab(nextStep.openSidebarTab)
if (
nextStep.deferTarget &&
nextStep.coachId &&
!targetMounted(nextStep.coachId)
) {
waitingForTarget.value = true
const found = await waitForTarget(
nextStep.coachId,
signal,
DEFER_TIMEOUT_MS
)
if (signal.aborted) {
if (stepController === controller) waitingForTarget.value = false
return
}
waitingForTarget.value = false
// Point at the timed-out step so telemetry reports it, and skip without
// the seen-flag so a missed target isn't permanent.
if (!found) {
stepIdx.value = idx
finish('skipped', { markSeen: false, skipReason: 'target_timeout' })
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('onboardingCoachmarks.loadError')
})
return
}
}
stepIdx.value = idx
trackTour('step_shown')
}
function openSidebarTab(tabId: string) {
const sidebar = useSidebarTabStore()
if (sidebar.activeSidebarTabId !== tabId) sidebar.toggleSidebarTab(tabId)
}
function next() {
if (waitingForTarget.value) return
if (isLast.value) {
finish('completed')
return
}
void showStep(stepIdx.value + 1)
}
function back() {
if (canGoBack.value) void showStep(stepIdx.value - 1)
}
function skip() {
finish('skipped')
}
function finish(
outcome: 'completed' | 'skipped',
{
markSeen = true,
skipReason = 'user'
}: { markSeen?: boolean; skipReason?: OnboardingTourSkipReason } = {}
) {
trackTour(outcome, outcome === 'skipped' ? skipReason : undefined)
stepController?.abort()
waitingForTarget.value = false
steps.value = []
stepIdx.value = 0
if (markSeen && activeTour.value) markTourSeen(activeTour.value)
activeTour.value = null
}
for (const [entryPath, trigger] of useTourTriggers()) {
watch(
trigger.autoOpen,
(visible) => {
if (visible) startTour(entryPath)
},
{ immediate: true }
)
watch(trigger.holds, (holding) => {
if (!holding && activeTour.value === entryPath)
finish('skipped', { markSeen: false, skipReason: 'trigger_lost' })
})
}
function hasSeenTour(entryPath: EntryPath): boolean {
return settingStore.get(TOUR_SEEN_SETTING).includes(entryPath)
}
function markTourSeen(entryPath: EntryPath) {
const seen = settingStore.get(TOUR_SEEN_SETTING)
if (seen.includes(entryPath)) return
void settingStore.set(TOUR_SEEN_SETTING, [...seen, entryPath])
}
function startTour(entryPath: EntryPath, force = false) {
if (steps.value.length) return
if (!force && hasSeenTour(entryPath)) return
const resolved = resolveSteps(TOURS[entryPath], targetMounted)
if (!resolved.length) return
steps.value = resolved
activeTour.value = entryPath
trackTour('started')
void showStep(0)
}
function replayTour(entryPath: EntryPath) {
startTour(entryPath, true)
}
return {
step,
isLast,
canGoBack,
title,
body,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget,
replayTour,
next,
back,
skip
}
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { resolveSteps } from './onboardingTours'
import type { CoachStep } from './onboardingTours'
function step(overrides: Partial<CoachStep> = {}): CoachStep {
return {
name: 'step',
placement: 'center',
...overrides
}
}
describe('resolveSteps', () => {
const isMounted = (mounted: boolean) => () => mounted
it('keeps a targetless step', () => {
const steps = [step()]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
it('drops a step whose target is not mounted', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(false))).toEqual([])
})
it('keeps a mounted step', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(true))).toEqual(steps)
})
it('keeps a deferred step even before its target mounts', () => {
const steps = [step({ coachId: 'app-run-button', deferTarget: true })]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
})

View File

@@ -0,0 +1,92 @@
export type EntryPath = 'appMode'
/** Setting holding the tours the user has completed or dismissed. */
export const TOUR_SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
export type CoachPlacement =
| 'left'
| 'right'
| 'center'
| 'bottom'
/** Left of the target, vertically centred on it (clamps to the viewport edge). */
| 'leftCenter'
/** Sits on whichever horizontal side of the target has more room. */
| 'auto'
/** Every element a tour can point at; the e2e drift guard asserts each resolves. */
export const COACH_IDS = {
appRunButton: 'app-run-button',
inputsList: 'inputs-list',
outputs: 'outputs',
assetsPanel: 'assets-panel'
} as const
export type CoachId = (typeof COACH_IDS)[keyof typeof COACH_IDS]
export interface CoachStep {
/**
* Derives the step's translation keys:
* `onboardingCoachmarks.<tour>.<name>.title|body`, plus optional
* `primary`/`skip` button-label overrides.
*/
name: string
/** Element to spotlight (the first laid-out registered candidate wins). */
coachId?: CoachId
placement: CoachPlacement
/** Open this sidebar tab when the step starts (e.g. to reveal its target). */
openSidebarTab?: string
/** Target mounts later (e.g. a dialog); wait for it instead of dropping the step. */
deferTarget?: boolean
/** Renders the landing dialog instead of a spotlight. */
landing?: boolean
image?: string
}
/**
* Fixes the running step set (and so the step count) at tour start: drops steps
* whose own target isn't mounted, keeping targetless and deferred steps.
*/
export function resolveSteps(
steps: CoachStep[],
isMounted: (id: CoachId) => boolean
): CoachStep[] {
return steps.filter(
(s) => !s.coachId || s.deferTarget || isMounted(s.coachId)
)
}
export const TOURS: Record<EntryPath, CoachStep[]> = {
appMode: [
{
name: 'landing',
landing: true,
placement: 'center',
image: '/assets/images/app-mode-landing.png'
},
{
name: 'inputs',
coachId: COACH_IDS.inputsList,
placement: 'auto',
deferTarget: true
},
{
name: 'run',
coachId: COACH_IDS.appRunButton,
placement: 'auto',
deferTarget: true
},
{
name: 'outputs',
coachId: COACH_IDS.outputs,
placement: 'leftCenter',
deferTarget: true
},
{
name: 'assets',
coachId: COACH_IDS.assetsPanel,
placement: 'auto',
deferTarget: true,
openSidebarTab: 'assets'
}
]
}

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import type { CoachId, CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
function step(coachId: CoachId): CoachStep {
return { name: 'step', placement: 'right', coachId }
}
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(10, 10, 80, 30)
return el
}
function hidden(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0)
return el
}
describe('useCoachmarkTarget', () => {
afterEach(clearCoachmarks)
function setup(coachId: CoachId) {
const scope = effectScope()
const stepRef = ref<CoachStep | null>(step(coachId))
const cardRef = ref<HTMLElement | null>(null)
const api = scope.run(() => useCoachmarkTarget(stepRef, cardRef))!
return { scope, api }
}
it('resolves the first laid-out candidate for the step target', () => {
const el = laidOut()
registerCoachmark('outputs', el)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(el)
scope.stop()
})
it('skips a registered target that is not laid out', () => {
registerCoachmark('outputs', hidden())
const laid = laidOut()
registerCoachmark('outputs', laid)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(laid)
scope.stop()
})
it('picks up a target that registers after the step starts', () => {
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBeNull()
const el = laidOut()
registerCoachmark('outputs', el)
expect(api.targetEl.value).toBe(el)
scope.stop()
})
})

View File

@@ -0,0 +1,136 @@
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
import type { Middleware, Placement, Rect } from '@floating-ui/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref, toValue, watch, watchEffect } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { CARD_GAP, VIEWPORT_MARGIN, topSafeInset } from './coachmarkLayout'
import { coachmarkElements, isLaidOut } from './coachmarkRegistry'
import type { CoachPlacement, CoachStep } from './onboardingTours'
// A target animating in via CSS transform reports through neither scroll nor
// resize events; poll each frame until its rect holds still this long.
const MOTION_SETTLE_MS = 250
const PLACEMENT: Record<
Exclude<CoachPlacement, 'auto' | 'center'>,
Placement
> = {
left: 'left-start',
right: 'right-start',
leftCenter: 'left',
bottom: 'bottom'
}
function floatingPlacement(step: CoachStep | null): Placement {
const placement = step?.placement
if (!placement || placement === 'auto' || placement === 'center')
return 'right-start'
return PLACEMENT[placement]
}
// Surfaces the measured target rect so the spotlight can trace it.
const captureReference: Middleware = {
name: 'captureReference',
fn: (state) => ({ data: { rect: state.rects.reference } })
}
function middleware(step: CoachStep | null, topInset: number): Middleware[] {
const list: Middleware[] = [offset(CARD_GAP)]
if (!step?.placement || step.placement === 'auto') list.push(flip())
// shift only guards the main axis by default; crossAxis keeps vertically-
// centred placements (leftCenter) on-screen too.
list.push(
shift({
crossAxis: true,
padding: {
top: topInset,
left: VIEWPORT_MARGIN,
right: VIEWPORT_MARGIN,
bottom: CARD_GAP
}
}),
captureReference
)
return list
}
/**
* Locates a step's target in the registry and positions the card beside it with
* Floating UI, following the target until its rect settles.
*/
export function useCoachmarkTarget(
step: MaybeRefOrGetter<CoachStep | null>,
cardRef: Ref<HTMLElement | null>
) {
const candidateEls = computed<readonly HTMLElement[]>(() => {
const id = toValue(step)?.coachId
return id ? coachmarkElements(id) : []
})
const targetEl = computed<HTMLElement | null>(
() => candidateEls.value.find(isLaidOut) ?? null
)
// The top bar's height only changes on resize, so read it once and refresh
// then — Floating UI re-runs the middleware every frame while tracking motion.
const topInset = ref(topSafeInset())
useEventListener('resize', () => {
topInset.value = topSafeInset()
})
const { floatingStyles, middlewareData, isPositioned, update } = useFloating(
targetEl,
cardRef,
{
strategy: 'fixed',
transform: false,
placement: () => floatingPlacement(toValue(step)),
middleware: () => middleware(toValue(step), topInset.value)
}
)
const targetRect = computed<DOMRect | null>(() => {
const data = middlewareData.value.captureReference as
| { rect: Rect }
| undefined
const rect = data?.rect
if (!rect || rect.width === 0) return null
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
})
const trackMotion = ref(false)
const rectKey = computed(() => {
const r = targetRect.value
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
})
watch(
() => toValue(step),
(s) => {
trackMotion.value = !!s?.deferTarget
},
{ immediate: true }
)
watch(rectKey, (_key, _prev, onCleanup) => {
if (!trackMotion.value) return
const timer = setTimeout(() => {
trackMotion.value = false
}, MOTION_SETTLE_MS)
onCleanup(() => clearTimeout(timer))
})
watchEffect((onCleanup) => {
const reference = targetEl.value
const floating = cardRef.value
if (!reference || !floating) return
onCleanup(
autoUpdate(reference, floating, update, {
animationFrame: trackMotion.value
})
)
})
return { targetEl, targetRect, floatingStyles, isPositioned }
}

View File

@@ -0,0 +1,30 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { EntryPath } from './onboardingTours'
export interface TourTrigger {
holds: ComputedRef<boolean>
autoOpen: ComputedRef<boolean>
}
/** Each tour's auto-open condition, paired with its entry path. */
export function useTourTriggers(): [EntryPath, TourTrigger][] {
const { mode } = useAppMode()
const appModeStore = useAppModeStore()
const desktopLayout = useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
const inAppMode = computed(() => desktopLayout.value && mode.value === 'app')
return [
[
'appMode',
{
holds: inAppMode,
autoOpen: computed(() => inAppMode.value && appModeStore.hasOutputs)
}
]
]
}

View File

@@ -0,0 +1,73 @@
import { cleanup, render } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick, ref, withDirectives } from 'vue'
import { coachmarkElements } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
import { vCoachmark } from './vCoachmark'
// Mounts a host element carrying `v-coachmark`, so the directive runs through
// Vue's real mount/update/unmount lifecycle rather than hand-called hooks.
function mountHost(initial: CoachId | null) {
const coachId = ref<CoachId | null>(initial)
const rev = ref(0)
const Host = defineComponent({
setup: () => () =>
withDirectives(
h('div', { 'data-testid': 'host', 'data-rev': rev.value }),
[[vCoachmark, coachId.value]]
)
})
return {
...render(Host),
coachId,
// Forces a re-render without touching the bound id, to exercise the
// no-op-update guard.
rerender: () => {
rev.value++
return nextTick()
}
}
}
describe('vCoachmark', () => {
afterEach(cleanup)
it('registers the element and mirrors the id to data-coach-id on mount', () => {
const { getByTestId } = mountHost('app-run-button')
const host = getByTestId('host')
expect(host.dataset.coachId).toBe('app-run-button')
expect(coachmarkElements('app-run-button')).toContain(host)
})
it('does not register an element bound to a falsy id', () => {
const { getByTestId } = mountHost(null)
expect(getByTestId('host').dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).toHaveLength(0)
})
it('moves the element to the new id when the binding changes', async () => {
const { getByTestId, coachId } = mountHost('app-run-button')
const host = getByTestId('host')
coachId.value = 'outputs'
await nextTick()
expect(host.dataset.coachId).toBe('outputs')
expect(coachmarkElements('app-run-button')).not.toContain(host)
expect(coachmarkElements('outputs')).toContain(host)
})
it('ignores a re-render that leaves the id unchanged', async () => {
const { getByTestId, rerender } = mountHost('app-run-button')
const host = getByTestId('host')
await rerender()
expect(coachmarkElements('app-run-button')).toEqual([host])
})
it('unregisters and clears data-coach-id on unmount', () => {
const { getByTestId, unmount } = mountHost('app-run-button')
const host = getByTestId('host')
unmount()
expect(host.dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).not.toContain(host)
})
})

View File

@@ -0,0 +1,29 @@
import type { Directive } from 'vue'
import { registerCoachmark, unregisterCoachmark } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
// The element's `data-coach-id` is the record of what it is registered as.
function sync(el: HTMLElement, id: CoachId | undefined | null) {
const prev = el.dataset.coachId as CoachId | undefined
if (prev === id) return
if (prev) {
unregisterCoachmark(prev, el)
delete el.dataset.coachId
}
if (id) {
el.dataset.coachId = id
registerCoachmark(id, el)
}
}
/**
* Marks an element as a coach-mark target: registers it in the reactive
* registry and mirrors the id to `data-coach-id` for e2e locators. A falsy
* value is a no-op, so it can be bound to a conditional id.
*/
export const vCoachmark: Directive<HTMLElement, CoachId | undefined | null> = {
mounted: (el, { value }) => sync(el, value),
updated: (el, { value }) => sync(el, value),
unmounted: (el) => sync(el, null)
}

View File

@@ -4,6 +4,7 @@ import {
SUPPORTED_LOCALE_OPTIONS
} from '@/locales/localeConfig'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
@@ -977,6 +978,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: TOUR_SEEN_SETTING,
name: 'Onboarding coachmark tours the user has already seen',
type: 'hidden',
defaultValue: [],
versionAdded: '1.48.0'
},
{
id: 'Comfy.InstalledVersion',
name: 'The frontend version that was running when the user first installed ComfyUI',

View File

@@ -17,6 +17,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -170,6 +172,13 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.dispatch((provider) => provider.trackOnboardingTour?.(stage, metadata))
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
}

View File

@@ -38,6 +38,8 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
RunButtonProperties,
ShareFlowMetadata,
ShellLayoutMetadata,
@@ -460,6 +462,35 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED]
])(
'trackOnboardingTour(%s) dispatches %s',
async ([stage, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const metadata: OnboardingTourMetadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
}
provider.trackOnboardingTour(stage, metadata)
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, metadata)
}
)
it('omits share_id from existing Mixpanel events', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()

View File

@@ -20,6 +20,8 @@ import type {
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
@@ -44,7 +46,7 @@ import type {
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -280,6 +282,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { TelemetryEvents } from '../../types'
import type { OnboardingTourStage } from '../../types'
const hoisted = vi.hoisted(() => {
const mockCapture = vi.fn()
@@ -718,6 +719,38 @@ describe('PostHogTelemetryProvider', () => {
shellLayoutMetadata
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED]
])(
'maps onboarding tour stage %s to %s',
async ([stage, expectedEvent]) => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
} as const
provider.trackOnboardingTour(stage, metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
expectedEvent,
metadata
)
}
)
})
describe('survey tracking', () => {

View File

@@ -24,6 +24,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -50,7 +52,11 @@ import type {
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
import {
CANCELLATION_STAGE_EVENTS,
OnboardingTourEvents,
TelemetryEvents
} from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -421,6 +427,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -94,6 +94,31 @@ export interface SurveyResponses {
usage?: string
}
export type OnboardingTourStage =
| 'started'
| 'step_shown'
| 'completed'
| 'skipped'
export type OnboardingTourSkipReason =
| 'user'
| 'target_timeout'
| 'trigger_lost'
/**
* `step_number` is 1-based and matches the "Step N of M" indicator the user
* sees, with `step_count` as M. Both `step_number` and `coach_id` are absent
* for steps with no numbered spotlight (e.g. the landing). `skip_reason` is
* present only on the `skipped` stage.
*/
export interface OnboardingTourMetadata {
tour: string
step_count: number
step_number?: number
coach_id?: string
skip_reason?: OnboardingTourSkipReason
}
export interface SurveyResponsesNormalized extends SurveyResponses {
industry_normalized?: string
industry_raw?: string
@@ -584,6 +609,12 @@ export interface TelemetryProvider {
// Survey flow events
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Onboarding coachmark tour events
trackOnboardingTour?(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void
// Email verification events
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
@@ -690,6 +721,12 @@ export const TelemetryEvents = {
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Onboarding Coachmarks
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
ONBOARDING_TOUR_STEP_SHOWN: 'app:onboarding_tour_step_shown',
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
@@ -753,6 +790,16 @@ export const TelemetryEvents = {
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
export const OnboardingTourEvents: Record<
OnboardingTourStage,
TelemetryEventName
> = {
started: TelemetryEvents.ONBOARDING_TOUR_STARTED,
step_shown: TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN,
completed: TelemetryEvents.ONBOARDING_TOUR_COMPLETED,
skipped: TelemetryEvents.ONBOARDING_TOUR_SKIPPED
}
export const CANCELLATION_STAGE_EVENTS = {
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
@@ -772,6 +819,7 @@ export type ExecutionTriggerSource =
*/
export type TelemetryEventProperties =
| AuthMetadata
| OnboardingTourMetadata
| AuthErrorMetadata
| SurveyResponses
| TemplateMetadata

View File

@@ -8,6 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
import { useOnboardingTourStore } from '@/platform/onboarding/onboardingTourStore'
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
@@ -90,6 +93,10 @@ async function runButtonClick(e: Event) {
function handleDragDrop() {
return widgetListRef.value?.handleDragDrop()
}
function replayAppModeTour() {
useOnboardingTourStore().replayTour('appMode')
}
</script>
<template>
<div
@@ -103,16 +110,29 @@ function handleDragDrop() {
class="flex h-12 items-center gap-2 border-x border-border-subtle bg-comfy-menu-bg px-4 py-2 contain-size"
>
<span
class="truncate font-bold"
class="min-w-0 flex-1 truncate font-bold"
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
<Button
v-tooltip.bottom="{
value: t('onboardingCoachmarks.appMode.replay'),
showDelay: 300,
hideDelay: 300
}"
variant="textonly"
size="icon"
:aria-label="t('onboardingCoachmarks.appMode.replay')"
class="rounded-lg border border-solid border-border-default text-muted-foreground hover:border-interface-stroke hover:text-base-foreground"
@click="replayAppModeTour"
>
<i class="icon-[lucide--circle-question-mark] size-4" />
</Button>
</section>
<div
class="flex h-full flex-col gap-2 border-x border-(--interface-stroke) bg-comfy-menu-bg px-2 md:border-y"
>
<section
v-coachmark="COACH_IDS.inputsList"
data-testid="linear-widgets"
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
>
@@ -153,31 +173,72 @@ function handleDragDrop() {
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">
<i v-if="batchCount == 1" class="icon-[lucide--chevron-down]" />
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
</Button>
</template>
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 min-w-40"
/>
</Popover>
<div v-coachmark="COACH_IDS.appRunButton">
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">
<i
v-if="batchCount == 1"
class="icon-[lucide--chevron-down]"
/>
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
</Button>
</template>
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 min-w-40"
/>
</Popover>
<Button
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div v-coachmark="COACH_IDS.appRunButton">
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<Button
v-else
variant="primary"
class="grow"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
@@ -190,40 +251,6 @@ function handleDragDrop() {
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<Button
v-else
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<FreeTierQuota />
</section>
</div>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const { setMode } = useAppMode()

View File

@@ -414,6 +414,7 @@ const zSettings = z.object({
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
'Comfy.Workflow.Persist': z.boolean(),
'Comfy.TutorialCompleted': z.boolean(),
'Comfy.OnboardingCoachmarks.Seen': z.array(z.string()),
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),

View File

@@ -29,6 +29,7 @@
<DesktopCloudNotificationController />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
<TourOverlay v-if="graphReady" />
</template>
<script setup lang="ts">
@@ -49,6 +50,7 @@ import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import TourOverlay from '@/platform/onboarding/TourOverlay.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
@@ -111,6 +113,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const graphReady = ref(false)
const { isBuilderMode, mode, isAppMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
@@ -294,6 +297,7 @@ void nextTick(() => {
})
const onGraphReady = () => {
graphReady.value = true
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {

View File

@@ -13,6 +13,8 @@ import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
import { useSettingStore } from '@/platform/settings/settingStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
@@ -38,6 +40,9 @@ const { hasOutputs } = storeToRefs(appModeStore)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const assetsPanelCoach = computed(() =>
activeTab.value?.id === 'assets' ? COACH_IDS.assetsPanel : undefined
)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
@@ -137,6 +142,7 @@ function dragDrop(e: DragEvent) {
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
v-coachmark="assetsPanelCoach"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
@@ -149,6 +155,7 @@ function dragDrop(e: DragEvent) {
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
v-coachmark="COACH_IDS.outputs"
data-testid="linear-center-panel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@@ -187,6 +194,7 @@ function dragDrop(e: DragEvent) {
/>
<div
v-else-if="activeTab"
v-coachmark="assetsPanelCoach"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />