Compare commits

...

3 Commits

Author SHA1 Message Date
PabloWiedemann
f8b0c0b58b feat(website): Redesign top navigation
Reorganize the website navigation around products, resources, and enterprise journeys. Preserve company destinations in the footer and add accessible mobile behavior and navigation analytics.
2026-07-16 11:40:33 -07:00
Benjamin Lu
d1d55585f9 [GTM-278] Report Firebase auth state to Desktop telemetry (#13687)
## Summary

Reports each hosted Cloud renderer's declarative Firebase auth state to
Desktop. Desktop remains the sole owner of PostHog identity and
arbitrates all live `WebContents` before changing the process-global
person.

Linear: [GTM-278](https://linear.app/comfyorg/issue/GTM-278) · Parent:
[GTM-93](https://linear.app/comfyorg/issue/GTM-93)

## Changes

- reports `pending` as soon as host auth synchronization starts
- waits for Firebase initialization, then reports `signed_out` or
`signed_in` with the Firebase UID
- reports restored sessions, logout, and direct account switches through
the same declarative state contract
- makes repeated same-UID Firebase updates a no-op
- adds the optional `reportFirebaseAuthState` contract to the Desktop
bridge types
- keeps host bridge failures from interrupting renderer startup or
Firebase auth

## Ownership and privacy

- the frontend never calls PostHog `identify`, `alias`, bind, or unbind
APIs
- browser PostHog provider behavior is unchanged
- the auth-state message is local to Desktop; Desktop #1272 applies its
own telemetry-consent gate before any PostHog identify or emission
- `enable_telemetry` controls only the frontend `HostTelemetrySink`
(renderer event forwarding), so this observer deliberately does not
share that rollout flag
- no canonical `comfy_user_id`, `/api/user` lookup, Cloud API
dependency, Redis state, token redemption, or website transport change
is added

## Review and rollout order

Review [Comfy-Desktop
#1272](https://github.com/Comfy-Org/Comfy-Desktop/pull/1272) first
because it defines the trusted state-reporting API and consensus policy.
Deploy this frontend first: the method is optional and therefore a no-op
on older Desktop builds. Then release Desktop #1272, and only afterward
deploy [comfy-router
#34](https://github.com/Comfy-Org/comfy-router/pull/34).

Auth [Comfy-Desktop
#1222](https://github.com/Comfy-Org/Comfy-Desktop/pull/1222) must not
become an identity authority; with Desktop #1272 its legacy imperative
bind is inert and the post-reload Firebase report is the source of
truth.

## Testing

- `pnpm test:unit src/platform/telemetry/hostUserIdSync.test.ts
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
— 53 tests passed
- `pnpm typecheck`
- targeted Oxlint and ESLint
- targeted `oxfmt --check`
- `pnpm knip`
- pre-commit typecheck/lint/format hooks
- pre-push `knip`
- `git diff --check`

No user-facing UI changes.
2026-07-16 04:22:41 +00:00
Benjamin Lu
98c654df20 fix: report page views to Customer.io in-app SDK (#13560)
## Summary

Report the current URL to Customer.io after its in-app plugin
initializes and on every client-side navigation, allowing queued web
in-app messages to satisfy page rules and render.

## Changes

- **What**: Wait for the in-app plugin to register, flush an initial
URL-based `page()` call, and consume the router's existing page-view
dispatches for subsequent SPA navigation.
- **Tests**: Cover initial page reporting, plugin-registration ordering,
and client-side route changes.

## Review Focus

Customer.io intentionally receives `page()` without an explicit page
name. Passing the telemetry page title would make the in-app SDK match
page rules against that title instead of the full URL; the live campaign
requires `contains cloud.comfy.org`.

Root cause: the production SDK debugger showed a correctly identified
user and active SSE connection, but `Route: NONE` and no eligible
messages because the provider did not implement `trackPageView()`.

Complements #13556, which addresses identity stitching for existing
email-keyed Customer.io profiles.

Validation: targeted unit tests (21/21), `pnpm typecheck`, targeted
ESLint and oxfmt checks, and the pre-push `knip` gate.
2026-07-16 02:02:59 +00:00
20 changed files with 1022 additions and 285 deletions

View File

@@ -4,9 +4,9 @@ import { test } from './fixtures/blockExternalMedia'
const TOP_LEVEL_LABELS = [
'Products',
'Pricing',
'Community',
'Company'
'Resources',
'Enterprise',
'Pricing'
] as const
test.describe('Desktop navigation @smoke', () => {
@@ -14,163 +14,263 @@ test.describe('Desktop navigation @smoke', () => {
await page.goto('/')
})
test('logo links to homepage', async ({ page }) => {
const logo = page.getByRole('link', { name: 'Comfy home' })
await expect(logo).toBeVisible()
await expect(logo).toHaveAttribute('href', '/')
})
test('has all top-level nav items', async ({ page }) => {
test('has the approved top-level order and working CTAs', async ({
page
}) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const labels = await desktopLinks
.locator('[data-nav-placement="desktop-top"]')
.allTextContents()
for (const label of TOP_LEVEL_LABELS) {
await expect(
desktopLinks.getByText(label, { exact: true }).first()
).toBeVisible()
}
})
expect(labels.map((label) => label.trim())).toEqual(TOP_LEVEL_LABELS)
await expect(nav.getByRole('link', { name: 'Comfy home' })).toHaveAttribute(
'href',
'/'
)
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
).toHaveAttribute('href', '/download')
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
).toBeVisible()
).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})
test.describe('Desktop dropdown @interaction', () => {
test.describe('Desktop dropdowns @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
test('Products contains only usable products', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: 'Products'
})
await productsButton.hover()
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
const dropdown = nav.getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Cloud',
'Comfy API',
'Comfy MCP'
]) {
await expect(
desktopLinks.getByRole('link', { name: new RegExp(item) }).first()
).toBeVisible()
}
for (const removedItem of [
'Features',
'Docs',
'Supported Models',
'Comfy Enterprise'
]) {
await expect(dropdown.getByText(item)).toBeVisible()
await expect(
desktopLinks.getByText(removedItem, { exact: true })
).toBeHidden()
}
})
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
test('Resources exposes the approved groups and destinations', async ({
page
}) => {
const desktopLinks = page
.getByRole('navigation', { name: 'Main navigation' })
.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Resources' }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
for (const group of ['Learn', 'Discover', 'Stay current', 'Community']) {
await expect(desktopLinks.getByText(group, { exact: true })).toBeVisible()
}
for (const item of [
'Docs',
'Learning',
'Blog',
'Comfy Hub',
'Gallery',
'Supported Models',
"What's New",
'Customer Stories',
'Discord',
'GitHub',
'YouTube',
'Affiliate Program'
]) {
await expect(
desktopLinks.getByRole('link', { name: new RegExp(item) }).first()
).toBeVisible()
}
await expect(
desktopLinks.getByRole('link', { name: /What's New/ })
).toHaveAttribute('href', '/launches')
await expect(
desktopLinks.getByRole('link', { name: /Docs/ })
).toHaveAttribute('rel', 'noopener noreferrer')
})
test('Enterprise provides overview, proof, sales, and support', async ({
page
}) => {
const desktopLinks = page
.getByRole('navigation', { name: 'Main navigation' })
.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Enterprise' }).hover()
await expect(
desktopLinks.getByRole('link', { name: /Enterprise Overview/ }).first()
).toHaveAttribute('href', '/cloud/enterprise')
await expect(
desktopLinks.getByRole('link', { name: /Customer Stories/ })
).toHaveAttribute('href', '/customers')
await expect(
desktopLinks.getByRole('link', { name: /Contact Sales/ })
).toHaveAttribute('href', '/contact')
await expect(
desktopLinks.getByRole('link', { name: /Support/ })
).toHaveAttribute('href', 'https://support.comfy.org/hc/en-us')
})
test('assigns a shared Customer Stories route to Enterprise', async ({
page
}) => {
await page.goto('/customers')
const desktopLinks = page.getByTestId('desktop-nav-links')
const resources = desktopLinks.getByRole('button', { name: 'Resources' })
const enterprise = desktopLinks.getByRole('button', {
name: 'Enterprise'
})
await expect(resources).not.toHaveAttribute('data-active')
await expect(enterprise).toHaveAttribute('data-active', '')
})
test('supports keyboard open and Escape focus return', async ({ page }) => {
const productsButton = page
.getByTestId('desktop-nav-links')
.getByRole('button', { name: 'Products' })
await productsButton.focus()
await page.keyboard.press('Enter')
await expect(productsButton).toHaveAttribute('aria-expanded', 'true')
await expect(productsButton).toHaveAttribute('aria-controls', /.+/)
await page.keyboard.press('Escape')
await expect(productsButton).toHaveAttribute('aria-expanded', 'false')
await expect(productsButton).toBeFocused()
})
test('closes when the pointer moves away', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
await nav
.getByTestId('desktop-nav-links')
.getByRole('button', { name: 'Products' })
.hover()
const desktopLink = nav
.getByTestId('desktop-nav-links')
.getByRole('link', { name: /Comfy Desktop/ })
await expect(desktopLink).toBeVisible()
const viewport = page.viewportSize()
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
await expect(comfyLocal).toBeHidden()
await expect(desktopLink).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
test('fits a laptop viewport and closes on outside click', async ({
page
}) => {
await page.setViewportSize({ width: 1024, height: 768 })
const desktopLinks = page.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Resources' }).click()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
const dropdown = desktopLinks
.getByTestId('nav-dropdown')
.filter({ hasText: 'Stay current' })
await expect(dropdown).toBeVisible()
await page.keyboard.press('Escape')
await expect(comfyLocal).toBeHidden()
const bounds = await dropdown.boundingBox()
expect(bounds).not.toBeNull()
if (!bounds) return
expect(bounds.x).toBeGreaterThanOrEqual(0)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1024)
expect(bounds.y).toBeGreaterThanOrEqual(0)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768)
await page.locator('main').click({ position: { x: 10, y: 500 } })
await expect(dropdown).toBeHidden()
})
})
test.describe('Mobile menu @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'Toggle menu' }).click()
})
test('hamburger button is visible', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('clicking hamburger opens mobile menu with nav items', async ({
test('uses the desktop ordering and accessible accordions', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
await expect(menu).toBeVisible()
const labels = await menu
.locator('[data-nav-placement="mobile-top"]')
.allTextContents()
expect(labels.map((label) => label.trim())).toEqual(TOP_LEVEL_LABELS)
for (const label of ['Products', 'Pricing', 'Community']) {
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
for (const label of ['Products', 'Resources', 'Enterprise']) {
const trigger = menu.getByRole('button', { name: label })
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await expect(trigger).toHaveAttribute('aria-controls', /.+/)
}
await expect(menu.getByRole('link', { name: 'Pricing' })).toHaveAttribute(
'href',
'/cloud/pricing'
)
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
test('preserves group hierarchy and closes the previous accordion', async ({
page
}) => {
const menu = page.getByRole('dialog')
const products = menu.getByRole('button', { name: 'Products' })
const resources = menu.getByRole('button', { name: 'Resources' })
for (const label of ['Products', 'Community']) {
await products.click()
await expect(menu.getByRole('region', { name: 'Products' })).toBeVisible()
await expect(menu.getByText('Create', { exact: true })).toBeVisible()
await expect(menu.getByText('Build', { exact: true })).toBeVisible()
await expect(menu.getByRole('link', { name: /Comfy MCP/ })).toBeVisible()
await resources.click()
await expect(menu.getByRole('region', { name: 'Products' })).toBeHidden()
const resourcesPanel = menu.getByRole('region', { name: 'Resources' })
await expect(resourcesPanel).toBeVisible()
for (const group of ['Learn', 'Discover', 'Stay current', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
resourcesPanel.getByText(group, { exact: true })
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
test('Escape closes an accordion, then the menu, and restores focus', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
await menu.getByRole('button', { name: 'Products' }).click()
const enterprise = menu.getByRole('button', { name: 'Enterprise' })
await enterprise.click()
await menu.getByRole('link', { name: /Contact Sales/ }).focus()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await page.keyboard.press('Escape')
await expect(enterprise).toHaveAttribute('aria-expanded', 'false')
await expect(enterprise).toBeFocused()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(menu).toBeHidden()
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeFocused()
})
})
@@ -179,20 +279,28 @@ test.describe('Footer @smoke', () => {
await page.goto('/')
})
test('footer is visible with link sections', async ({ page }) => {
test('preserves Company and legal destinations', async ({ page }) => {
const footer = page.locator('footer')
await expect(footer).toBeVisible()
for (const heading of ['Products', 'Resources', 'Company']) {
await expect(
footer.getByRole('heading', { name: heading }).first()
).toBeVisible()
}
})
test('copyright text is visible', async ({ page }) => {
await expect(
page.locator('footer').getByText(/© \d{4} Comfy Org/)
).toBeVisible()
for (const [label, href] of [
['About', '/about'],
['Careers', '/careers'],
['Contact', '/contact'],
['Brand', '/brand'],
['Terms of Service', '/terms-of-service'],
['Enterprise MSA', '/enterprise-msa'],
['Privacy Policy', '/privacy-policy'],
['Press', 'mailto:press@comfy.org']
]) {
const link = footer.getByRole('link', { name: label }).first()
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', href)
}
})
})

View File

@@ -2,6 +2,7 @@
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import { externalLinks, getRoutes } from '../../../config/routes.ts'
import { captureNavigationClick } from '../../../scripts/posthog'
import GitHubStarBadge from '../GitHubStarBadge.vue'
import HeaderMainDesktop from './HeaderMainDesktop.vue'
import HeaderMainMobile from './HeaderMainMobile.vue'
@@ -29,12 +30,21 @@ const ctaButtons = [
primary: true
}
]
function handleNavigationClick(event: MouseEvent) {
if (!(event.target instanceof Element)) return
const trackedElement = event.target.closest<HTMLElement>('[data-nav-label]')
const item = trackedElement?.dataset.navLabel
if (!item) return
captureNavigationClick(item, trackedElement.dataset.navPlacement ?? 'header')
}
</script>
<template>
<nav
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
@click.capture="handleNavigationClick"
>
<a
:href="routes.home"

View File

@@ -6,6 +6,7 @@ import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLi
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
import { cn } from '@comfyorg/tailwind-utils'
import {
isHrefActive,
@@ -26,7 +27,11 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
if (navItem.href) return isHrefActive(navItem.href, path)
return (
navItem.columns?.some((column) =>
column.items.some((item) => isHrefActive(item.href, path))
column.items.some(
(item) =>
item.contributesToParentActive !== false &&
isHrefActive(item.href, path)
)
) ?? false
)
}
@@ -42,6 +47,8 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<template v-if="navItem.columns?.length">
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
:data-nav-label="navItem.analyticsId"
data-nav-placement="desktop-top"
>
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
@@ -49,18 +56,38 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
</span>
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">
<ul
:class="
cn(
'flex w-max max-w-dvw gap-10',
navItem.layout === 'wide' && 'gap-16'
)
"
>
<NavFeaturedCard
v-if="navItem.featured"
:featured="navItem.featured"
/>
<NavColumn
v-for="column in navItem.columns"
:key="column.header"
:column="column"
:locale="locale"
:current-path="currentPath"
/>
<li>
<ul
:class="
cn(
'grid grid-cols-2 gap-x-10 gap-y-8',
navItem.layout === 'wide' && 'gap-x-16'
)
"
>
<NavColumn
v-for="column in navItem.columns"
:key="column.header"
:column="column"
:locale="locale"
:current-path="currentPath"
:parent-analytics-id="navItem.analyticsId"
:wide="navItem.layout === 'wide'"
/>
</ul>
</li>
</ul>
</NavigationMenuContent>
</template>
@@ -69,6 +96,8 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
as-child
:active="isNavItemActive(navItem, currentPath)"
:class="navigationMenuTriggerStyle()"
:data-nav-label="navItem.analyticsId"
data-nav-placement="desktop-top"
>
<a :href="navItem.href" class="ppformula-text-center">{{
navItem.label

View File

@@ -1,22 +1,23 @@
<script setup lang="ts">
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { getMainNavigation } from '../../../data/mainNavigation'
import { getRoutes } from '../../../config/routes.ts'
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronDown } from '@lucide/vue'
import { onUnmounted, ref, watch } from 'vue'
import { getRoutes } from '../../../config/routes.ts'
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import { getMainNavigation } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
@@ -25,11 +26,20 @@ const mainNavigation = getMainNavigation(locale)
const isOpen = ref(false)
const activeSection = ref<string | null>(null)
const activeItem = computed(() =>
mainNavigation.find(
(item) => item.label === activeSection.value && item.columns
)
)
function toggleSection(sectionId: string) {
activeSection.value = activeSection.value === sectionId ? null : sectionId
}
function closeSectionAndFocus(sectionId: string) {
activeSection.value = null
document.getElementById(`mobile-nav-${sectionId}-trigger`)?.focus()
}
function handleSectionEscape(sectionId: string, event: KeyboardEvent) {
if (activeSection.value !== sectionId) return
event.stopPropagation()
closeSectionAndFocus(sectionId)
}
watch(isOpen, (open) => {
if (open) {
@@ -77,91 +87,92 @@ onUnmounted(() => {
</div>
<div class="relative mt-4 flex-1 overflow-hidden">
<!-- Top-level nav -->
<nav
:class="
cn(
'absolute inset-0 overflow-y-auto p-1',
activeItem ? 'opacity-0' : ''
)
"
class="absolute inset-0 overflow-y-auto p-1"
:aria-label="t('nav.menu', locale)"
:inert="activeItem ? true : undefined"
>
<ul class="flex flex-col gap-y-8">
<ul class="flex flex-col gap-y-4">
<li v-for="item in mainNavigation" :key="item.label">
<template v-if="item.columns">
<Button
:id="`mobile-nav-${item.analyticsId}-trigger`"
type="button"
variant="navMuted"
:class="
cn(
activeSection === item.analyticsId &&
'text-primary-comfy-yellow'
)
"
:aria-expanded="activeSection === item.analyticsId"
:aria-controls="`mobile-nav-${item.analyticsId}-panel`"
:data-nav-label="item.analyticsId"
data-nav-placement="mobile-top"
@click="toggleSection(item.analyticsId)"
@keydown.esc="handleSectionEscape(item.analyticsId, $event)"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
<template #append>
<ChevronDown
class="size-6"
:class="
activeSection === item.analyticsId && 'rotate-180'
"
aria-hidden="true"
/>
</template>
</Button>
<div
v-show="activeSection === item.analyticsId"
:id="`mobile-nav-${item.analyticsId}-panel`"
role="region"
:aria-labelledby="`mobile-nav-${item.analyticsId}-trigger`"
class="mt-6 flex flex-col gap-y-10 pb-4 pl-4"
@keydown.esc="handleSectionEscape(item.analyticsId, $event)"
>
<div
v-for="column in item.columns"
:key="column.header"
class="flex flex-col gap-y-3"
>
<p
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
>
{{ column.header }}
</p>
<Button
v-for="link in column.items"
:key="link.label"
:href="link.href"
variant="nav"
as="a"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
:data-nav-label="link.analyticsId"
:data-nav-placement="`mobile-${item.analyticsId}`"
@click="isOpen = false"
>
<NavLinkContent :item="link" :locale="locale" />
</Button>
</div>
</div>
</template>
<Button
:as="item.columns ? 'button' : 'a'"
v-else
:href="item.href"
variant="navMuted"
:type="item.columns ? 'button' : undefined"
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
as="a"
:data-nav-label="item.analyticsId"
data-nav-placement="mobile-top"
@click="isOpen = false"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
<template #append>
<ChevronRight class="size-7" />
</template>
</Button>
</li>
</ul>
</nav>
<!-- Drill-down sub-panel -->
<div
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
:class="
activeItem
? 'translate-x-0'
: 'pointer-events-none translate-x-full'
"
:inert="activeItem ? undefined : true"
:aria-hidden="!activeItem"
>
<div class="size-full overflow-y-auto py-8">
<Button
type="button"
variant="link"
@click="activeSection = null"
>
<template #prepend>
<ChevronLeft />
</template>
{{ t('nav.back', locale) }}
</Button>
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
<div
v-for="column in activeItem.columns"
:key="column.header"
class="flex flex-col gap-y-3"
>
<p
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
>
{{ column.header }}
</p>
<Button
v-for="link in column.items"
:key="link.label"
:href="link.href"
variant="nav"
as="a"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
>
<NavLinkContent :item="link" :locale="locale" />
</Button>
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
/>
</div>
</div>
</SheetContent>
</Sheet>

View File

@@ -5,12 +5,19 @@ import { isHrefActive } from '../../../composables/useCurrentPath'
import type { NavColumn } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavLinkContent from './NavLinkContent.vue'
import { cn } from '@comfyorg/tailwind-utils'
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
defineProps<{
column: NavColumn
locale: Locale
currentPath: string
parentAnalyticsId: string
wide?: boolean
}>()
</script>
<template>
<li class="flex flex-col space-y-4">
<li :class="cn('flex w-44 flex-col space-y-4', wide && 'w-52')">
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
{{ column.header }}
</p>
@@ -25,7 +32,8 @@ defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
:href="item.href"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener noreferrer' : undefined"
class="whitespace-nowrap"
:data-nav-label="item.analyticsId"
:data-nav-placement="`desktop-${parentAnalyticsId}`"
>
<NavLinkContent :item="item" :locale="locale" />
</a>

View File

@@ -11,6 +11,8 @@ defineProps<{ featured: NavFeatured }>()
<a
:href="featured.cta.href"
:aria-label="featured.cta.ariaLabel"
:data-nav-label="featured.analyticsId"
data-nav-placement="desktop-featured"
class="group/pill-trigger relative block"
>
<img

View File

@@ -10,11 +10,14 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<span class="ppformula-text-center whitespace-nowrap">{{
item.label
}}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"
aria-hidden="true"
/>
</span>
</template>

View File

@@ -85,6 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.contact', locale), href: routes.contact },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
@@ -95,7 +96,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
const contactColumn: { title: string; links: FooterLink[] } = {
title: t('footer.contact', locale),
links: [
{ label: t('footer.sales', locale), href: routes.contact },
{
label: t('footer.support', locale),
href: externalLinks.support,

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { NavItem } from './mainNavigation'
import { getMainNavigation } from './mainNavigation'
function getColumns(item: NavItem) {
if (!item.columns) throw new Error(`${item.label} must be a dropdown`)
return item.columns
}
describe('getMainNavigation', () => {
const navigation = getMainNavigation('en')
it('uses the approved top-level order', () => {
expect(navigation.map((item) => item.label)).toEqual([
'Products',
'Resources',
'Enterprise',
'Pricing'
])
})
it('limits Products to usable products grouped by Create and Build', () => {
const columns = getColumns(navigation[0])
expect(columns.map((column) => column.header)).toEqual(['Create', 'Build'])
expect(
columns.flatMap((column) => column.items.map((item) => item.label))
).toEqual(['Comfy Desktop', 'Comfy Cloud', 'Comfy API', 'Comfy MCP'])
})
it('organizes Resources into Learn, Discover, Stay current, and Community', () => {
const columns = getColumns(navigation[1])
expect(
columns.map((column) => [
column.header,
column.items.map((item) => item.label)
])
).toEqual([
['Learn', ['Learning', 'Docs', 'Blog', 'YouTube']],
['Discover', ['Comfy Hub', 'Gallery', 'Supported Models']],
['Stay current', ["What's New", 'Customer Stories']],
['Community', ['Affiliate Program', 'Discord', 'GitHub']]
])
expect(columns[2].items[0].href).toBe('/launches')
})
it('builds Enterprise as a compact buyer hub', () => {
const columns = getColumns(navigation[2])
expect(
columns.map((column) => [
column.header,
column.items.map((item) => item.label)
])
).toEqual([
['Evaluate', ['Enterprise Overview', 'Customer Stories']],
['Contact', ['Contact Sales', 'Support']]
])
expect(columns[0].items[0].href).toBe('/cloud/enterprise')
expect(columns[1].items[0].href).toBe('/contact')
})
it('includes Customer Stories in both discovery and buying journeys', () => {
const customerStoryPlacements = navigation
.flatMap((item) => item.columns ?? [])
.flatMap((column) => column.items)
.filter((item) => item.analyticsId === 'customer-stories')
expect(customerStoryPlacements).toHaveLength(2)
expect(
customerStoryPlacements.every((item) => item.href === '/customers')
).toBe(true)
expect(customerStoryPlacements[0].contributesToParentActive).toBe(false)
expect(customerStoryPlacements[1].contributesToParentActive).toBeUndefined()
})
})

View File

@@ -5,6 +5,8 @@ import { t } from '../i18n/translations'
export type NavColumnItem = {
label: string
href: string
analyticsId: string
contributesToParentActive?: boolean
badge?: 'new'
external?: boolean
}
@@ -18,6 +20,7 @@ export type NavFeatured = {
imageSrc: string
imageAlt?: string
title: string
analyticsId: string
cta: {
label: string
ariaLabel?: string
@@ -28,13 +31,16 @@ export type NavFeatured = {
export type NavItem =
| {
label: string
analyticsId: string
columns: NavColumn[]
featured?: NavFeatured
layout?: 'default' | 'wide'
badge?: 'new'
href?: never
}
| {
label: string
analyticsId: string
href: string
badge?: 'new'
columns?: never
@@ -46,11 +52,12 @@ export function getMainNavigation(locale: Locale): NavItem[] {
return [
{
label: t('nav.products', locale),
badge: 'new',
analyticsId: 'products',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
analyticsId: 'comfy-mcp-featured',
cta: {
label: t('cta.getStarted', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
@@ -59,110 +66,140 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
columns: [
{
header: t('nav.products', locale),
header: t('nav.colCreate', locale),
items: [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{
label: t('nav.comfyApi', locale),
href: routes.api,
badge: 'new'
label: t('nav.comfyLocal', locale),
href: routes.download,
analyticsId: 'comfy-desktop'
},
{
label: t('nav.comfyEnterprise', locale),
href: routes.cloudEnterprise
label: t('nav.comfyCloud', locale),
href: routes.cloud,
analyticsId: 'comfy-cloud'
}
]
},
{
header: t('nav.colFeatures', locale),
header: t('nav.colBuild', locale),
items: [
{
label: t('nav.comfyApi', locale),
href: routes.api,
analyticsId: 'comfy-api',
badge: 'new'
},
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
analyticsId: 'comfy-mcp',
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{ label: t('nav.supportedModels', locale), href: routes.models },
{
label: t('nav.docs', locale),
href: externalLinks.docs,
external: true
}
]
}
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
label: t('nav.resources', locale),
analyticsId: 'resources',
layout: 'wide',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),
title: t('nav.featuredCommunityTitle', locale),
imageAlt: t('nav.featuredResourcesAlt', locale),
title: t('nav.featuredResourcesTitle', locale),
analyticsId: 'featured-workflow',
cta: {
label: t('cta.watchDemo', locale),
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
ariaLabel: t('nav.featuredResourcesCtaAria', locale),
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
}
},
columns: [
{
header: t('nav.colPrograms', locale),
header: t('nav.colLearn', locale),
items: [
{ label: t('nav.comfyHub', locale), href: externalLinks.workflows },
{ label: t('nav.gallery', locale), href: routes.gallery },
{
label: t('nav.affiliates', locale),
href: routes.affiliates,
badge: 'new'
},
{
label: t('nav.learning', locale),
href: routes.learning,
analyticsId: 'learning',
badge: 'new'
}
]
},
{
header: t('nav.colConnect', locale),
items: [
},
{
label: t('nav.discord', locale),
href: externalLinks.discord,
label: t('nav.docs', locale),
href: externalLinks.docs,
analyticsId: 'docs',
external: true
},
{
label: t('nav.github', locale),
href: externalLinks.github,
label: t('nav.blogs', locale),
href: externalLinks.blog,
analyticsId: 'blog',
external: true
},
{
label: t('nav.youtube', locale),
href: externalLinks.youtube,
analyticsId: 'youtube',
external: true
}
]
},
{
header: t('nav.colDiscover', locale),
items: [
{
label: t('nav.comfyHub', locale),
href: externalLinks.workflows,
analyticsId: 'comfy-hub'
},
{
label: t('nav.gallery', locale),
href: routes.gallery,
analyticsId: 'gallery'
},
{
label: t('nav.supportedModels', locale),
href: routes.models,
analyticsId: 'supported-models'
}
]
},
{
header: t('nav.colStayCurrent', locale),
items: [
{
label: t('nav.whatsNew', locale),
href: routes.launches,
analyticsId: 'whats-new',
badge: 'new'
},
{
label: t('nav.customerStories', locale),
href: routes.customers,
analyticsId: 'customer-stories',
contributesToParentActive: false
}
]
},
{
header: t('nav.community', locale),
items: [
{
label: t('nav.affiliateProgram', locale),
href: routes.affiliates,
analyticsId: 'affiliate-program',
badge: 'new'
},
{
label: t('nav.discord', locale),
href: externalLinks.discord,
analyticsId: 'discord',
external: true
},
{
label: t('nav.reddit', locale),
href: externalLinks.reddit,
external: true
},
{
label: t('nav.x', locale),
href: externalLinks.x,
external: true
},
{
label: t('nav.instagram', locale),
href: externalLinks.instagram,
label: t('nav.github', locale),
href: externalLinks.github,
analyticsId: 'github',
external: true
}
]
@@ -170,43 +207,57 @@ export function getMainNavigation(locale: Locale): NavItem[] {
]
},
{
label: t('nav.company', locale),
label: t('nav.enterprise', locale),
analyticsId: 'enterprise',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
imageAlt: t('nav.featuredCompanyAlt', locale),
title: t('nav.featuredCompanyTitle', locale),
imageAlt: t('nav.featuredEnterpriseAlt', locale),
title: t('nav.featuredEnterpriseTitle', locale),
analyticsId: 'enterprise-featured',
cta: {
label: t('cta.watchNow', locale),
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
href: '/customers#hero-video'
label: t('nav.enterpriseOverview', locale),
ariaLabel: t('nav.featuredEnterpriseCtaAria', locale),
href: routes.cloudEnterprise
}
},
columns: [
{
header: t('nav.company', locale),
header: t('nav.colEvaluate', locale),
items: [
{ label: t('nav.aboutUs', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.contact', locale), href: routes.contact }
{
label: t('nav.enterpriseOverview', locale),
href: routes.cloudEnterprise,
analyticsId: 'enterprise-overview'
},
{
label: t('nav.customerStories', locale),
href: routes.customers,
analyticsId: 'customer-stories'
}
]
},
{
header: t('nav.colMore', locale),
header: t('nav.colContact', locale),
items: [
{
label: t('nav.customerStories', locale),
href: routes.customers
label: t('nav.contactSales', locale),
href: routes.contact,
analyticsId: 'contact-sales'
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
label: t('nav.support', locale),
href: externalLinks.support,
analyticsId: 'support',
external: true
}
]
}
]
},
{
label: t('nav.pricing', locale),
href: routes.cloudPricing,
analyticsId: 'pricing'
}
]
}

View File

@@ -2251,6 +2251,7 @@ const translations = {
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
'nav.community': { en: 'Community', 'zh-CN': '社区' },
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
@@ -2275,6 +2276,17 @@ const translations = {
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.whatsNew': { en: "What's New", 'zh-CN': '最新动态' },
'nav.enterpriseOverview': {
en: 'Enterprise Overview',
'zh-CN': '企业版概览'
},
'nav.contactSales': { en: 'Contact Sales', 'zh-CN': '联系销售' },
'nav.support': { en: 'Support', 'zh-CN': '支持' },
'nav.affiliateProgram': {
en: 'Affiliate Program',
'zh-CN': '联盟计划'
},
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
@@ -2301,6 +2313,13 @@ const translations = {
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
'nav.colCreate': { en: 'Create', 'zh-CN': '创作' },
'nav.colBuild': { en: 'Build', 'zh-CN': '构建' },
'nav.colLearn': { en: 'Learn', 'zh-CN': '学习' },
'nav.colDiscover': { en: 'Discover', 'zh-CN': '探索' },
'nav.colStayCurrent': { en: 'Stay current', 'zh-CN': '保持关注' },
'nav.colEvaluate': { en: 'Evaluate', 'zh-CN': '评估' },
'nav.colContact': { en: 'Contact', 'zh-CN': '联系我们' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -2325,6 +2344,30 @@ const translations = {
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
},
'nav.featuredResourcesTitle': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'nav.featuredResourcesAlt': {
en: 'Sky Replacement workflow demo image',
'zh-CN': '天空替换工作流演示图片'
},
'nav.featuredResourcesCtaAria': {
en: 'Watch the Sky Replacement demo',
'zh-CN': '观看天空替换演示'
},
'nav.featuredEnterpriseTitle': {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
},
'nav.featuredEnterpriseAlt': {
en: 'Comfy Enterprise customer story',
'zh-CN': 'Comfy 企业版客户故事'
},
'nav.featuredEnterpriseCtaAria': {
en: 'Explore Comfy Enterprise',
'zh-CN': '探索 Comfy 企业版'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',
'zh-CN': '天空替换'

View File

@@ -78,3 +78,24 @@ describe('captureDownloadClick', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})
describe('captureNavigationClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the navigation item and placement', async () => {
const { initPostHog, captureNavigationClick } = await import('./posthog')
initPostHog()
captureNavigationClick('customer-stories', 'desktop-enterprise')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:navigation_clicked',
{
item: 'customer-stories',
placement: 'desktop-enterprise'
}
)
})
})

View File

@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
console.error('PostHog download click capture failed', error)
}
}
export function captureNavigationClick(item: string, placement: string) {
if (!initialized) return
try {
posthog.capture('website:navigation_clicked', { item, placement })
} catch (error) {
console.error('PostHog navigation click capture failed', error)
}
}

View File

@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export type ComfyDesktop2FirebaseAuthState =
| { status: 'pending' }
| { status: 'signed_out' }
| { status: 'signed_in'; userId: string }
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
}
export interface ComfyDesktop2Bridge {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"version": "0.1.3",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",

View File

@@ -19,6 +19,7 @@ import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
@@ -141,6 +142,10 @@ app
modules: [VueFireAuth()]
})
if (isCloud && hasHostTelemetryBridge) {
syncHostUserIdWithFirebaseAuth()
}
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,

View File

@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import { nextTick } from 'vue'
type MockAuthStore = {
isInitialized: boolean
currentUser: { uid: string } | null
}
const hoisted = vi.hoisted(() => ({
authStore: null as unknown as MockAuthStore
}))
vi.mock('@/stores/authStore', async () => {
const { reactive } = await vi.importActual<typeof VueModule>('vue')
hoisted.authStore = reactive<MockAuthStore>({
isInitialized: false,
currentUser: null
})
return { useAuthStore: () => hoisted.authStore }
})
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
const stopHandles: Array<() => void> = []
function installTelemetryBridge() {
const reportFirebaseAuthState = vi.fn()
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: {
capture: vi.fn(),
reportFirebaseAuthState
}
}
return { reportFirebaseAuthState }
}
function startSync(): void {
const stop = syncHostUserIdWithFirebaseAuth()
if (stop) stopHandles.push(stop)
}
describe('host user ID sync', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.authStore.isInitialized = false
hoisted.authStore.currentUser = null
})
afterEach(() => {
while (stopHandles.length) stopHandles.pop()?.()
delete window.__comfyDesktop2
})
it('waits for Firebase auth initialization before reporting a user', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'pending'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
it('reports a restored Firebase session immediately', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('reports an initially signed-out Firebase session', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_out' }]
])
})
it('reports signed out when Firebase finishes initialization', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
})
it('reports account switches, logout, and subsequent login', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-b'
})
hoisted.authStore.currentUser = null
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }],
[{ status: 'signed_in', userId: 'firebase-user-b' }],
[{ status: 'signed_out' }],
[{ status: 'signed_in', userId: 'firebase-user-c' }]
])
})
it('does not report again when Firebase replaces the user object with the same UID', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
reportFirebaseAuthState.mockImplementationOnce(() => {
throw new Error('host unavailable')
})
expect(() => startSync()).not.toThrow()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
})

View File

@@ -0,0 +1,49 @@
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useAuthStore } from '@/stores/authStore'
function safelyReportFirebaseAuthState(report: () => void): void {
try {
report()
} catch {
// A host bridge failure must not block renderer startup or Firebase auth.
}
}
/**
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
* Must run after Pinia and VueFire are installed.
*/
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
const telemetry = window.__comfyDesktop2?.Telemetry
if (!telemetry) return
// Register this Cloud renderer before Firebase resolves. Desktop may host
// multiple Cloud main frames whose isolated browser partitions have
// different auth states, so main owns all cross-WebContents arbitration.
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
)
const authStore = useAuthStore()
return watch(
() =>
authStore.isInitialized
? (authStore.currentUser?.uid ?? null)
: undefined,
(userId) => {
if (userId === undefined) return
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.(
userId === null
? { status: 'signed_out' }
: { status: 'signed_in', userId }
)
)
},
{ immediate: true }
)
}

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn().mockResolvedValue(undefined),
page: vi.fn(),
track: vi.fn().mockResolvedValue(undefined),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
@@ -108,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => {
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('reports the current page after registering the in-app plugin', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.page.mock.invocationCallOrder[0]
)
})
it('queues page views until the in-app plugin is registered', async () => {
let resolveRegistration: (() => void) | undefined
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve
})
hoisted.analytics.register.mockReturnValue(registration)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).not.toHaveBeenCalled()
resolveRegistration?.()
await vi.waitFor(() =>
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
)
})
it('reports client-side route changes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).not.toHaveBeenCalled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
})
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const registrationError = new Error('in-app setup failed')
hoisted.analytics.register.mockRejectedValue(registrationError)
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'execution_start',
SOURCE
)
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith(
'Failed to initialize Customer.io in-app plugin:',
registrationError
)
provider.trackAddApiCreditButtonClicked()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:add_api_credit_button_clicked',
SOURCE
)
)
provider.trackPageView('settings', {
path: 'https://cloud.comfy.org/settings'
})
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()

View File

@@ -8,6 +8,7 @@ import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
PageViewMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
@@ -41,7 +42,9 @@ interface CustomerIoIdentity {
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private isPageViewTrackingReady = false
private eventQueue: QueuedEvent[] = []
private pageViewQueued = false
private identifiedUser: CustomerIoIdentity | null = null
private sessionIdentity: CustomerIoIdentity | null = null
private operationQueue: Promise<void> = Promise.resolve()
@@ -61,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
void analytics.register(
const inAppRegistration = analytics.register(
InAppPlugin({
siteId,
events: null,
@@ -97,6 +100,17 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
void this.flushQueue()
void inAppRegistration
.catch((error) => {
console.error(
'Failed to initialize Customer.io in-app plugin:',
error
)
})
.finally(() => {
this.isPageViewTrackingReady = true
this.flushPageView()
})
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
@@ -198,6 +212,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
}
private sendPageView(): void {
void this.analytics?.page()?.catch((error) => {
console.error('Failed to track Customer.io page view:', error)
})
}
private flushPageView(): void {
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
return
}
this.pageViewQueued = false
this.sendPageView()
}
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
if (!this.isEnabled) return
if (!this.isPageViewTrackingReady) {
this.pageViewQueued = true
return
}
this.sendPageView()
}
trackAuth(metadata: AuthMetadata): void {
const identity = metadata.user_id
? {