Compare commits

...

1 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
13 changed files with 656 additions and 283 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)
}
}