Merge branch 'main' into jaewon/fe-729-delete-is-asset-api-enabled

This commit is contained in:
dante01yoon
2026-06-20 08:34:15 +09:00
141 changed files with 4704 additions and 1939 deletions

View File

@@ -0,0 +1,142 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -92,9 +92,7 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View File

@@ -2,6 +2,13 @@ import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const TOP_LEVEL_LABELS = [
'Products',
'Pricing',
'Community',
'Company'
] as const
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
@@ -17,14 +24,10 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
for (const label of TOP_LEVEL_LABELS) {
await expect(
desktopLinks.getByText(label, { exact: true }).first()
).toBeVisible()
}
})
@@ -49,11 +52,11 @@ test.describe('Desktop dropdown @interaction', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: /PRODUCTS/i
name: 'Products'
})
await productsButton.hover()
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
const dropdown = nav.getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Cloud',
@@ -67,19 +70,20 @@ test.describe('Desktop dropdown @interaction', () => {
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/i }).hover()
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
const viewport = page.viewportSize()
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
await expect(comfyLocal).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/i }).hover()
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
@@ -105,11 +109,11 @@ test.describe('Mobile menu @mobile', () => {
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
const menu = page.getByRole('dialog')
await expect(menu).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
for (const label of ['Products', 'Pricing', 'Community']) {
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
}
})
@@ -118,24 +122,14 @@ test.describe('Mobile menu @mobile', () => {
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
const menu = page.getByRole('dialog')
await menu.getByRole('button', { name: 'Products' }).click()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -25,12 +25,15 @@
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@lucide/vue": "catalog:",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
@@ -43,6 +46,7 @@
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from './badge.variants'
import { badgeVariants } from './badge.variants'
const { variant, class: className } = defineProps<{
variant?: BadgeVariants['variant']
class?: string
}>()
</script>
<template>
<span :class="cn(badgeVariants({ variant }), className)">
<slot />
</span>
</template>

View File

@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<div class="lg:flex lg:gap-16">
<!-- Desktop sticky nav -->
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">
<CategoryNav
:categories="categories"
@@ -135,7 +135,7 @@ function scrollToSection(id: string) {
>
<h2
v-if="section.hasTitle"
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
class="mb-6 text-2xl font-light text-primary-comfy-canvas"
>
{{ t(key(section.id, 'title'), locale) }}
</h2>
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
<!-- Paragraph -->
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
v-html="t(key(section.id, `block.${i}`), locale)"
/>
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
locale
).split('\n')"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-2"
class="flex items-start gap-2 text-primary-comfy-canvas"
>
<span
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
@@ -187,7 +187,7 @@ function scrollToSection(id: string) {
locale
).split('\n')"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-3"
class="flex items-start gap-3 text-primary-comfy-canvas"
>
<span
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
@@ -205,7 +205,7 @@ function scrollToSection(id: string) {
:alt="t(key(section.id, `block.${i}.alt`), locale)"
class="w-full rounded-2xl object-cover"
/>
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
{{ t(key(section.id, `block.${i}.caption`), locale) }}
</figcaption>
</figure>
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
"
>
<p
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
>
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
</p>
@@ -238,17 +238,17 @@ function scrollToSection(id: string) {
<SectionLabel>
{{ t(key(section.id, `block.${i}.label`), locale) }}
</SectionLabel>
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
<p class="mt-2 text-sm font-semibold text-primary-comfy-canvas">
{{ t(key(section.id, `block.${i}.name`), locale) }}
</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
{{ t(key(section.id, `block.${i}.role`), locale) }}
</p>
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
{{ t(key(section.id, `block.${i}.name2`), locale) }}
</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
{{ t(key(section.id, `block.${i}.role2`), locale) }}
</p>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import { externalLinks, getRoutes } from '../../../config/routes.ts'
import GitHubStarBadge from '../GitHubStarBadge.vue'
import HeaderMainDesktop from './HeaderMainDesktop.vue'
import HeaderMainMobile from './HeaderMainMobile.vue'
import Button from '@/components/ui/button/Button.vue'
const { locale = 'en', githubStars = '' } = defineProps<{
locale?: Locale
githubStars?: string
}>()
const routes = getRoutes(locale)
const ctaButtons = [
{
prefix: t('nav.ctaDesktopPrefix', locale),
core: t('nav.ctaDesktopCore', locale),
ariaLabel: t('nav.downloadLocal', locale),
href: routes.download,
primary: false
},
{
prefix: t('nav.ctaCloudPrefix', locale),
core: t('nav.ctaCloudCore', locale),
ariaLabel: t('nav.launchCloud', locale),
href: externalLinks.cloud,
primary: true
}
]
</script>
<template>
<nav
class="fixed inset-x-0 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"
>
<a
:href="routes.home"
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
aria-label="Comfy home"
>
<img
src="/icons/logomark.svg"
alt="Comfy"
class="col-span-full row-span-full h-8"
/>
<div
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
>
<img
src="/icons/logo.svg"
alt="Comfy"
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
/>
</div>
</a>
<!-- Desktop nav links -->
<HeaderMainDesktop :locale class="hidden lg:block" />
<HeaderMainMobile :locale class="lg:hidden" />
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<Button
v-for="cta in ctaButtons"
:key="cta.href"
as="a"
:href="cta.href"
:variant="cta.primary ? 'default' : 'outline'"
:aria-label="cta.ariaLabel"
>
<span
><span class="hidden xl:inline-block">{{ cta.prefix }}&nbsp;</span
>{{ cta.core }}</span
>
</Button>
</div>
</nav>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import NavigationMenu from '@/components/ui/navigation-menu/NavigationMenu.vue'
import NavigationMenuContent from '@/components/ui/navigation-menu/NavigationMenuContent.vue'
import NavigationMenuItem from '@/components/ui/navigation-menu/NavigationMenuItem.vue'
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
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 {
isHrefActive,
useCurrentPath
} from '../../../composables/useCurrentPath'
import { getMainNavigation } from '../../../data/mainNavigation'
import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
const currentPath = useCurrentPath()
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))
) ?? false
)
}
</script>
<template>
<NavigationMenu data-testid="desktop-nav-links">
<NavigationMenuList>
<NavigationMenuItem
v-for="navItem in mainNavigation"
:key="navItem.label"
>
<template v-if="navItem.columns?.length">
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max 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"
/>
</ul>
</NavigationMenuContent>
</template>
<NavigationMenuLink
v-else
as-child
:active="isNavItemActive(navItem, currentPath)"
:class="navigationMenuTriggerStyle()"
>
<a :href="navItem.href" class="ppformula-text-center">{{
navItem.label
}}</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</template>

View File

@@ -0,0 +1,167 @@
<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 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'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
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
)
)
watch(isOpen, (open) => {
if (open) {
lockScroll()
} else {
unlockScroll()
activeSection.value = null
}
})
onUnmounted(() => {
if (isOpen.value) unlockScroll({ skipRestore: true })
})
</script>
<template>
<div>
<Sheet v-model:open="isOpen">
<SheetTrigger
:aria-label="t('nav.toggleMenu', locale)"
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
>
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
</SheetTrigger>
<SheetContent
side="right"
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
:close-label="t('nav.close', locale)"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
<SheetDescription>
{{ t('nav.mobileMenuDescription', locale) }}
</SheetDescription>
</SheetHeader>
<div>
<a
:href="routes.home"
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
>
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
<span class="sr-only">{{ t('nav.home', locale) }}</span>
</a>
</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' : ''
)
"
:aria-label="t('nav.menu', locale)"
:inert="activeItem ? true : undefined"
>
<ul class="flex flex-col gap-y-8">
<li v-for="item in mainNavigation" :key="item.label">
<Button
:as="item.columns ? 'button' : 'a'"
variant="navMuted"
:type="item.columns ? 'button' : undefined"
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<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>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
import { isHrefActive } from '../../../composables/useCurrentPath'
import type { NavColumn } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavLinkContent from './NavLinkContent.vue'
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
</script>
<template>
<li class="flex flex-col space-y-4">
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
{{ column.header }}
</p>
<ul class="flex flex-col">
<li v-for="item in column.items" :key="item.label">
<NavigationMenuLink
as-child
:active="isHrefActive(item.href, currentPath)"
class="hover:bg-transparency-white-t4"
>
<a
:href="item.href"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener noreferrer' : undefined"
class="whitespace-nowrap"
>
<NavLinkContent :item="item" :locale="locale" />
</a>
</NavigationMenuLink>
</li>
</ul>
</li>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import ButtonPill from '@/components/ui/button-pill/ButtonPill.vue'
import type { NavFeatured } from '../../../data/mainNavigation'
defineProps<{ featured: NavFeatured }>()
</script>
<template>
<li class="shrink-0">
<a
:href="featured.cta.href"
:aria-label="featured.cta.ariaLabel"
class="group/pill-trigger relative block"
>
<img
class="aspect-4/3 w-62 max-w-none rounded-xl"
:src="featured.imageSrc"
:alt="featured.imageAlt ?? ''"
/>
<p class="mt-4 font-extrabold uppercase">
{{ featured.title }}
</p>
<div class="mt-1">
<ButtonPill as="span" icon-position="left" variant="ghost">
{{ featured.cta.label }}
</ButtonPill>
</div>
</a>
</li>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"
/>
</span>
</template>

View File

@@ -1,82 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
import {
maskRevealButtonBadgeVariants,
maskRevealButtonVariants,
maskRevealLabelVariants
} from './maskRevealButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = true,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: MaskRevealButtonVariants['variant']
size?: MaskRevealButtonVariants['size']
iconPosition?: MaskRevealButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="maskRevealLabelVariants()"
>
<slot />
</span>
<span
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -1,186 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import BrandButton from './BrandButton.vue'
import type { NavLink } from './NavDesktopLink.vue'
interface CtaLink {
label: string
href: string
primary: boolean
}
const {
open = false,
navigating = false,
links = [],
ctaLinks = [],
locale = 'en'
} = defineProps<{
open?: boolean
navigating?: boolean
links?: NavLink[]
ctaLinks?: CtaLink[]
locale?: Locale
}>()
const emit = defineEmits<{
close: []
}>()
const menuRef = ref<HTMLElement | undefined>()
const activeSection = ref<string | null>(null)
const activeSectionItems = computed(
() => links.find((l) => l.label === activeSection.value)?.items
)
function onNavigate() {
activeSection.value = null
emit('close')
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab') return
const menu = menuRef.value
if (!menu) return
const focusable = [...menu.querySelectorAll<HTMLElement>(FOCUSABLE)]
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(
() => open,
async (isOpen) => {
if (isOpen) {
lockScroll()
await nextTick()
const menu = menuRef.value
const firstFocusable = menu?.querySelector<HTMLElement>(FOCUSABLE)
firstFocusable?.focus()
menu?.addEventListener('keydown', trapFocus)
} else {
menuRef.value?.removeEventListener('keydown', trapFocus)
unlockScroll({ skipRestore: navigating })
}
}
)
onUnmounted(() => {
menuRef.value?.removeEventListener('keydown', trapFocus)
if (open) unlockScroll({ skipRestore: true })
})
</script>
<template>
<div
v-show="open"
id="site-mobile-menu"
ref="menuRef"
role="dialog"
aria-modal="true"
:inert="!open"
:aria-label="t('nav.menu', locale)"
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
>
<!-- Main list -->
<template v-if="!activeSection">
<div class="flex flex-1 flex-col gap-8">
<template v-for="link in links" :key="link.label">
<button
v-if="link.items"
class="text-primary-comfy-canvas text-left text-3xl font-medium"
@click="activeSection = link.label"
>
{{ link.label }}
</button>
<a
v-else
:href="link.href"
class="text-primary-comfy-canvas text-3xl font-medium"
@click="onNavigate"
>
{{ link.label }}
</a>
</template>
</div>
<div class="flex flex-col gap-3">
<BrandButton
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:variant="cta.primary ? 'solid' : 'outline'"
size="lg"
class="w-full"
>
{{ cta.label }}
</BrandButton>
</div>
</template>
<!-- Drill-down sub-menu -->
<template v-else>
<div class="flex flex-1 flex-col">
<button
class="text-primary-comfy-yellow mb-6 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
@click="activeSection = null"
>
<span
aria-hidden="true"
class="bg-primary-comfy-yellow size-3 -translate-y-px rotate-180"
style="
mask: url('/icons/arrow-right.svg') center / contain no-repeat;
"
/>
{{ t('nav.back', locale) }}
</button>
<p class="text-primary-warm-gray mb-8 text-sm font-bold uppercase">
{{ activeSection }}
</p>
<div class="flex flex-col gap-8 pl-2">
<a
v-for="item in activeSectionItems"
:key="item.href"
:href="item.href"
class="text-primary-comfy-canvas flex items-center gap-3 text-3xl font-medium"
@click="onNavigate"
>
{{ item.label }}
<span
v-if="item.badge"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-semibold"
>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</a>
</div>
</div>
</template>
</div>
</template>

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
type NavDropdownItem = {
label: string
href: string
badge?: string
external?: boolean
}
export type NavLink = {
label: string
href?: string
items?: NavDropdownItem[]
}
const {
link,
currentPath,
isOpen = false
} = defineProps<{
link: NavLink
currentPath: string
isOpen?: boolean
}>()
const emit = defineEmits<{
(e: 'open', label: string): void
(e: 'close'): void
(e: 'toggle', label: string): void
}>()
</script>
<template>
<div
class="relative"
@mouseenter="link.items?.length && emit('open', link.label)"
@mouseleave="emit('close')"
@focusin="link.items?.length && emit('open', link.label)"
@focusout="emit('close')"
>
<button
v-if="link.items?.length"
type="button"
:class="
cn(
'group flex cursor-pointer items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
link.items.some((item) => currentPath === item.href)
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
)
"
aria-haspopup="true"
:aria-expanded="isOpen"
@click="emit('toggle', link.label)"
>
{{ link.label }}
<span
aria-hidden="true"
:class="
cn(
'text-base leading-none transition-colors',
link.items.some((item) => currentPath === item.href)
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas group-hover:text-primary-warm-gray'
)
"
>
</span>
</button>
<a
v-else
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
:class="
cn(
'flex items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
currentPath === link.href
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
)
"
>
{{ link.label }}
</a>
<div
v-if="link.items?.length"
v-show="isOpen"
data-testid="nav-dropdown"
class="bg-transparency-ink-t80 absolute top-full left-0 w-max rounded-xl p-2 shadow-lg backdrop-blur-2xl backdrop-saturate-150"
>
<a
v-for="item in link.items"
:key="item.href"
:href="item.href"
:aria-current="currentPath === item.href ? 'page' : undefined"
:class="
cn(
'flex items-center gap-2 rounded-sm p-2 text-xs font-medium tracking-wide transition-colors',
currentPath === item.href
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:bg-transparency-white-t4 hover:text-white'
)
"
@click="emit('close')"
>
{{ item.label }}
<span
v-if="item.badge"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"
src="/icons/arrow-up-right.svg"
alt=""
class="ml-auto size-4"
aria-hidden="true"
/>
</a>
</div>
</div>
</template>

View File

@@ -1,84 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { PillButtonVariants } from './pillButton.variants'
import {
pillButtonBadgeVariants,
pillButtonVariants
} from './pillButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = false,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: PillButtonVariants['variant']
size?: PillButtonVariants['size']
iconPosition?: PillButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:class="
cn(
'relative leading-none transition-all duration-500',
hideLabel && 'opacity-0 group-hover:opacity-100'
)
"
>
<slot />
</span>
<span
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -1,262 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
breakpointsTailwind,
useBreakpoints,
useEventListener,
whenever
} from '@vueuse/core'
import { nextTick, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import BrandButton from './BrandButton.vue'
import GitHubStarBadge from './GitHubStarBadge.vue'
import MobileMenu from './MobileMenu.vue'
import NavDesktopLink from './NavDesktopLink.vue'
import type { NavLink } from './NavDesktopLink.vue'
const { locale = 'en', githubStars = '' } = defineProps<{
locale?: Locale
githubStars?: string
}>()
const routes = getRoutes(locale)
const navLinks: NavLink[] = [
{
label: t('nav.products', 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: t('nav.badgeNew', locale)
},
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
items: [
{
label: t('nav.comfyHub', locale),
href: externalLinks.workflows,
badge: t('nav.badgeNew', locale)
},
{ label: t('nav.gallery', locale), href: routes.gallery }
]
},
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
external: true
},
{
label: t('nav.github', locale),
href: externalLinks.github,
external: true
},
{
label: t('nav.discord', locale),
href: externalLinks.discord,
external: true
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
external: true
},
{
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
}
]
},
{
label: t('nav.company', locale),
items: [
{ label: t('nav.aboutUs', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.customerStories', locale), href: routes.customers }
]
}
]
const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'DESKTOP',
href: routes.download,
primary: false
},
{
label: t('nav.launchCloud', locale),
prefix: 'LAUNCH',
core: 'CLOUD',
href: externalLinks.cloud,
primary: true
}
]
const currentPath = ref('')
const openDesktopDropdown = ref<string | null>(null)
const mobileMenuOpen = ref(false)
const isNavigating = ref(false)
const hamburgerRef = ref<HTMLButtonElement | undefined>()
function closeMobileMenu() {
mobileMenuOpen.value = false
hamburgerRef.value?.focus()
}
function toggleDesktopDropdown(label: string) {
openDesktopDropdown.value = openDesktopDropdown.value === label ? null : label
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeMobileMenu()
openDesktopDropdown.value = null
}
}
async function onNavigate() {
isNavigating.value = true
closeMobileMenu()
openDesktopDropdown.value = null
currentPath.value = window.location.pathname
await nextTick()
isNavigating.value = false
}
const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('lg')
whenever(isDesktop, () => {
mobileMenuOpen.value = false
// Don't focus hamburger when transitioning to desktop — it's hidden
})
onMounted(() => {
currentPath.value = window.location.pathname
useEventListener(document, 'keydown', onKeydown)
useEventListener(document, 'astro:after-swap', onNavigate)
})
</script>
<template>
<MobileMenu
:open="mobileMenuOpen"
:navigating="isNavigating"
:links="navLinks"
:cta-links="ctaButtons"
:locale="locale"
@close="closeMobileMenu"
/>
<nav
class="fixed inset-x-0 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"
>
<a
:href="routes.home"
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
aria-label="Comfy home"
>
<img
src="/icons/logomark.svg"
alt="Comfy"
class="col-span-full row-span-full h-8"
/>
<div
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
>
<img
src="/icons/logo.svg"
alt="Comfy"
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
/>
</div>
</a>
<!-- Desktop nav links -->
<div
data-testid="desktop-nav-links"
class="hidden items-center gap-[clamp(1rem,2.5vw,2.5rem)] lg:flex"
>
<NavDesktopLink
v-for="link in navLinks"
:key="link.label"
:link="link"
:current-path="currentPath"
:is-open="openDesktopDropdown === link.label"
@open="openDesktopDropdown = $event"
@close="openDesktopDropdown = null"
@toggle="toggleDesktopDropdown"
/>
</div>
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<BrandButton
v-for="cta in ctaButtons"
:key="cta.href"
:href="cta.href"
:variant="cta.primary ? 'solid' : 'outline'"
size="nav"
:aria-label="cta.label"
>
<span
class="inline-block max-w-0 overflow-hidden align-bottom transition-[max-width] duration-300 ease-in-out xl:max-w-28"
aria-hidden="true"
>{{ cta.prefix }}&nbsp;</span
>{{ cta.core }}
</BrandButton>
</div>
<!-- Mobile hamburger -->
<button
ref="hamburgerRef"
:class="
cn(
'flex size-10 items-center justify-center rounded-xl lg:hidden',
mobileMenuOpen
? 'border-primary-comfy-yellow border-2 bg-transparent'
: 'bg-primary-comfy-yellow'
)
"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<img
v-if="!mobileMenuOpen"
src="/icons/breadthumb.svg"
alt=""
class="h-3"
aria-hidden="true"
/>
<img
v-else
src="/icons/close.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</button>
</nav>
</template>

View File

@@ -1,17 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
}
},
defaultVariants: {
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,110 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const maskRevealButtonVariants = cva({
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealLabelVariants = cva({
base: [
'relative inline-block align-baseline',
'[will-change:mask-size,-webkit-mask-size]',
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
].join(' ')
})
export type MaskRevealButtonVariants = VariantProps<
typeof maskRevealButtonVariants
>

View File

@@ -1,116 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const pillButtonVariants = cva({
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
},
{
size: 'md',
iconPosition: 'right',
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
},
{
size: 'sm',
iconPosition: 'left',
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
},
{
size: 'md',
iconPosition: 'left',
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const pillButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>

View File

@@ -58,13 +58,13 @@ function handleLogoLoad() {
</SectionLabel>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
class="mt-4 text-4xl/tight font-light text-primary-comfy-canvas lg:text-6xl"
>
{{ t('customers.hero.heading', locale) }}
</h1>
<p
ref="bodyRef"
class="text-primary-comfy-canvas mt-6 max-w-lg text-base"
class="mt-6 max-w-lg text-base text-primary-comfy-canvas"
>
{{ t('customers.hero.body', locale) }}
</p>
@@ -72,7 +72,12 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<div
id="hero-video"
ref="videoRef"
class="max-w-9xl mx-auto scroll-mt-24 px-4 pb-20 lg:scroll-mt-36 lg:px-20 lg:pb-40"
>
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -35,7 +35,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
{{ t(story.category, locale) }}
</span>
<h3
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
>
{{ t(story.title, locale) }}
</h3>

View File

@@ -19,7 +19,7 @@ const {
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
</h2>
@@ -35,18 +35,18 @@ const {
</a>
<div class="flex flex-col gap-6">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
{{ title }}
</h3>
<a :href="href" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
</span>

View File

@@ -21,7 +21,7 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
</h2>
@@ -37,18 +37,18 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
</a>
<div class="flex flex-col gap-6">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
{{ nextTitle }}
</h3>
<a :href="nextHref" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
</span>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 12"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<path
d="M20 1C20 1.55228 19.5523 2 19 2H17.5C16.6716 2 16 2.67157 16 3.5C16 4.32843 16.6716 5 17.5 5H19C19.5523 5 20 5.44772 20 6C20 6.55228 19.5523 7 19 7H7.5C6.67157 7 6 7.67157 6 8.5C6 9.32843 6.67157 10 7.5 10H19C19.5523 10 20 10.4477 20 11C20 11.5523 19.5523 12 19 12H1C0.447715 12 0 11.5523 0 11C0 10.4477 0.447715 10 1 10H2.5C3.32843 10 4 9.32843 4 8.5C4 7.67157 3.32843 7 2.5 7H1C0.447715 7 0 6.55228 0 6C0 5.44772 0.447715 5 1 5H12.5C13.3284 5 14 4.32843 14 3.5C14 2.67157 13.3284 2 12.5 2H1C0.447716 2 0 1.55228 0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -2,7 +2,7 @@
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import Badge from '../ui/badge/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'

View File

@@ -8,8 +8,8 @@ import {
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -76,13 +76,14 @@ const activeTutorial = () =>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="sm"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
@@ -98,7 +99,7 @@ const activeTutorial = () =>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</ButtonMask>
</div>
<ul class="flex flex-wrap gap-2">

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from '.'
import { badgeVariants } from '.'
const {
variant,
size,
class: className,
prependIcon,
appendIcon
} = defineProps<{
variant?: BadgeVariants['variant']
size?: BadgeVariants['size']
class?: string
prependIcon?: Component
appendIcon?: Component
}>()
</script>
<template>
<span
data-slot="badge"
:data-variant="variant"
:data-size="size"
:class="cn(badgeVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />
</slot>
<span class="ppformula-text-center">
<slot />
</span>
<slot name="append">
<component :is="appendIcon" v-if="appendIcon" />
</slot>
</span>
</template>

View File

@@ -0,0 +1,24 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
}
},
defaultVariants: {
size: 'md',
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MaskRevealButton from './MaskRevealButton.vue'
import ButtonMask from './ButtonMask.vue'
const meta: Meta<typeof MaskRevealButton> = {
title: 'Website/Common/MaskRevealButton',
component: MaskRevealButton,
const meta: Meta<typeof ButtonMask> = {
title: 'Website/UI/ButtonMask',
component: ButtonMask,
tags: ['autodocs'],
decorators: [
() => ({
@@ -12,22 +12,19 @@ const meta: Meta<typeof MaskRevealButton> = {
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
as: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
options: ['button', 'a']
},
asChild: { control: 'boolean' },
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
options: ['default', 'lg', 'icon']
},
iconPosition: {
control: { type: 'select' },
@@ -41,57 +38,57 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { href: '#' },
args: { as: 'a', href: '#' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
template: `<ButtonMask v-bind="args">Try Workflow</ButtonMask>`
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
args: { as: 'a', href: '#', variant: 'ghost' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
template: '<ButtonMask v-bind="args">Read More</ButtonMask>'
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
args: { as: 'a', href: '#', iconPosition: 'left' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
template: '<ButtonMask v-bind="args">Go Back</ButtonMask>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
export const DefaultSolid: Story = {
args: { as: 'a', href: '#', size: 'default' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
template: '<ButtonMask v-bind="args">Try Workflow</ButtonMask>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
args: { as: 'a', href: '#', size: 'lg' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
template: `<ButtonMask v-bind="args">Let's Collaborate</ButtonMask>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
args: { as: 'a', href: '#' },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: `
<MaskRevealButton v-bind="args">
<ButtonMask v-bind="args">
Next Step
<template #icon>
<svg
@@ -106,57 +103,53 @@ export const WithCustomIcon: Story = {
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</ButtonMask>
`
})
}
export const LabelVisible: Story = {
args: { href: '#', hideLabel: false },
args: { as: 'a', href: '#', hideLabel: false },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template:
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
template: '<ButtonMask v-bind="args">Always Visible</ButtonMask>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { MaskRevealButton },
components: { ButtonMask },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
template: '<ButtonMask v-bind="args">Unavailable</ButtonMask>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { MaskRevealButton },
components: { ButtonMask },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
<ButtonMask as="a" href="#" variant="solid" size="default">Default</ButtonMask>
<ButtonMask as="a" href="#" variant="solid" size="lg">Large</ButtonMask>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
<ButtonMask as="a" href="#" variant="ghost" size="default">Default</ButtonMask>
<ButtonMask as="a" href="#" variant="ghost" size="lg">Large</ButtonMask>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
<ButtonMask as="a" href="#" iconPosition="left" size="default">Default</ButtonMask>
<ButtonMask as="a" href="#" iconPosition="left" size="lg">Large</ButtonMask>
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import { Primitive } from 'reka-ui'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonMaskVariants } from '.'
import {
BUTTON_MASK_LABEL_CLASS,
buttonMaskBadgeVariants,
buttonMaskVariants
} from '.'
interface Props extends PrimitiveProps {
variant?: ButtonMaskVariants['variant']
size?: ButtonMaskVariants['size']
iconPosition?: ButtonMaskVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
iconPosition,
hideLabel = true,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button-mask"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonMaskVariants({ variant, size, iconPosition }), className)"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="BUTTON_MASK_LABEL_CLASS"
>
<slot />
</span>
<span
:class="buttonMaskBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<ChevronRight class="size-4" :stroke-width="2" />
</slot>
</span>
</span>
</Primitive>
</template>

View File

@@ -0,0 +1,94 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export { default as ButtonMask } from './ButtonMask.vue'
export const buttonMaskVariants = cva({
base: 'group/button-mask relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{ size: 'default', iconPosition: 'right', class: 'ps-12 pe-4' },
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
{ size: 'default', iconPosition: 'left', class: 'ps-4 pe-12' },
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export const buttonMaskBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
variants: {
variant: {
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
default: 'size-8',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'default',
iconPosition: 'right',
class: 'right-1 group-hover/button-mask:right-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover/button-mask:right-[calc(100%-52px)]'
},
{
size: 'default',
iconPosition: 'left',
class: 'left-1 group-hover/button-mask:left-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover/button-mask:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export const BUTTON_MASK_LABEL_CLASS = [
'ppformula-text-center relative inline-block align-baseline',
'[will-change:mask-size,-webkit-mask-size]',
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
'group-hover/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
'group-focus-visible/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
].join(' ')
export type ButtonMaskVariants = VariantProps<typeof buttonMaskVariants>

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PillButton from './PillButton.vue'
import ButtonPill from './ButtonPill.vue'
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
const meta: Meta<typeof ButtonPill> = {
title: 'Website/UI/ButtonPill',
component: ButtonPill,
tags: ['autodocs'],
decorators: [
() => ({
@@ -12,22 +12,19 @@ const meta: Meta<typeof PillButton> = {
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
as: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
options: ['button', 'a']
},
asChild: { control: 'boolean' },
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
options: ['default', 'lg', 'icon']
},
iconPosition: {
control: { type: 'select' },
@@ -41,57 +38,57 @@ export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { href: '#' },
args: { as: 'a', href: '#' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
})
}
export const AsButton: Story = {
args: { type: 'button' },
args: { as: 'button', type: 'button' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Submit</PillButton>'
template: '<ButtonPill v-bind="args">Submit</ButtonPill>'
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
args: { as: 'a', href: '#', variant: 'ghost' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Read More</PillButton>'
template: '<ButtonPill v-bind="args">Read More</ButtonPill>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
export const DefaultSolid: Story = {
args: { as: 'a', href: '#', size: 'default' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
args: { as: 'a', href: '#', size: 'lg' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
args: { as: 'a', href: '#' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: `
<PillButton v-bind="args">
<ButtonPill v-bind="args">
Next Step
<template #icon>
<svg
@@ -106,57 +103,55 @@ export const WithCustomIcon: Story = {
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</PillButton>
</ButtonPill>
`
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
args: { as: 'a', href: '#', iconPosition: 'left' },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Go Back</PillButton>'
template: '<ButtonPill v-bind="args">Go Back</ButtonPill>'
})
}
export const RevealLabelOnHover: Story = {
args: { href: '#', hideLabel: true },
args: { as: 'a', href: '#', hideLabel: true },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { PillButton },
components: { ButtonPill },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Unavailable</PillButton>'
template: '<ButtonPill v-bind="args">Unavailable</ButtonPill>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { PillButton },
components: { ButtonPill },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
<ButtonPill as="a" href="#" variant="solid" size="default">Default</ButtonPill>
<ButtonPill as="a" href="#" variant="solid" size="lg">Large</ButtonPill>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
<ButtonPill as="a" href="#" variant="ghost" size="default">Default</ButtonPill>
<ButtonPill as="a" href="#" variant="ghost" size="lg">Large</ButtonPill>
</div>
</div>
</div>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import { Primitive } from 'reka-ui'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonPillVariants } from '.'
import { buttonPillBadgeVariants, buttonPillVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonPillVariants['variant']
size?: ButtonPillVariants['size']
iconPosition?: ButtonPillVariants['iconPosition']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
iconPosition,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button-pill"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonPillVariants({ variant, size, iconPosition }), className)"
>
<span
:class="
cn(
'ppformula-text-center relative leading-none transition-all duration-500'
)
"
>
<slot />
</span>
<span
:class="buttonPillBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<ChevronRight class="size-4" :stroke-width="2" />
</slot>
</span>
</span>
</Primitive>
</template>

View File

@@ -0,0 +1,102 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonPillVariants = cva({
base: 'group/button-pill isolate relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'default',
iconPosition: 'right',
class:
'ps-6 pe-14 group-hover/pill-trigger:ps-14 group-hover/pill-trigger:pe-6 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class:
'ps-8 pe-16 group-hover/pill-trigger:ps-16 group-hover/pill-trigger:pe-8 hover:ps-16 hover:pe-8'
},
{
size: 'default',
iconPosition: 'left',
class:
'ps-14 pe-6 group-hover/pill-trigger:ps-6 group-hover/pill-trigger:pe-14 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class:
'ps-16 pe-8 group-hover/pill-trigger:ps-8 group-hover/pill-trigger:pe-16 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export const buttonPillBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
variants: {
variant: {
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
default: 'size-8',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'default',
iconPosition: 'right',
class:
'right-1 group-hover/button-pill:right-[calc(100%-36px)] group-hover/pill-trigger:right-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'right',
class:
'right-1 group-hover/button-pill:right-[calc(100%-52px)] group-hover/pill-trigger:right-[calc(100%-52px)]'
},
{
size: 'default',
iconPosition: 'left',
class:
'left-1 group-hover/button-pill:left-[calc(100%-36px)] group-hover/pill-trigger:left-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'left',
class:
'left-1 group-hover/button-pill:left-[calc(100%-52px)] group-hover/pill-trigger:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export type ButtonPillVariants = VariantProps<typeof buttonPillVariants>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { Component, HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
prependIcon?: Component
appendIcon?: Component
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled,
prependIcon,
appendIcon
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />
</slot>
<span class="ppformula-text-center">
<slot />
</span>
<slot name="append">
<component :is="appendIcon" v-if="appendIcon" />
</slot>
</Primitive>
</template>

View File

@@ -0,0 +1,31 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const buttonVariants = cva(
[
"focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 aria-invalid:bg-destructive aria-invalid:hover:bg-destructive/90 inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-2xl text-sm font-bold tracking-wider whitespace-nowrap transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
],
{
variants: {
size: {
default: 'h-10 px-6 py-2.5',
lg: 'h-14 px-8 py-4 text-base'
},
variant: {
default:
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from 'reka-ui'
import { NavigationMenuRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import NavigationMenuViewport from './NavigationMenuViewport.vue'
const {
viewport = true,
class: className,
...restProps
} = defineProps<
NavigationMenuRootProps & {
class?: HTMLAttributes['class']
viewport?: boolean
}
>()
const emits = defineEmits<NavigationMenuRootEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="
cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className
)
"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type {
NavigationMenuContentEmits,
NavigationMenuContentProps
} from 'reka-ui'
import { NavigationMenuContent, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuContentProps & { class?: HTMLAttributes['class'] }
>()
const emits = defineEmits<NavigationMenuContentEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="
cn(
'top-0 left-0 w-full px-8 py-6 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:border-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-3xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:text-primary-comfy-canvas group-data-[viewport=false]/navigation-menu:shadow-sm group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95',
className
)
"
>
<slot />
</NavigationMenuContent>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { NavigationMenuItemProps } from 'reka-ui'
import { NavigationMenuItem } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="restProps"
:class="cn('relative', className)"
>
<slot />
</NavigationMenuItem>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { NavigationMenuLinkEmits, NavigationMenuLinkProps } from 'reka-ui'
import { NavigationMenuLink, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuLinkProps & { class?: HTMLAttributes['class'] }
>()
const emits = defineEmits<NavigationMenuLinkEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuLink
data-slot="navigation-menu-link"
v-bind="forwarded"
:class="
cn(
'data-active:text-primary-comfy-yellow focus:bg-transparency-white-t4 ring-primary-comfy-yellow outline-primary-comfy-yellow flex flex-col gap-1 rounded-xl p-2 text-sm transition-[color,box-shadow] hover:text-white focus:text-white focus-visible:ring-4 focus-visible:outline-1 data-active:bg-transparent data-active:hover:bg-transparent [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
className
)
"
>
<slot />
</NavigationMenuLink>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { NavigationMenuListProps } from 'reka-ui'
import { NavigationMenuList, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuListProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className
)
"
>
<slot />
</NavigationMenuList>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ChevronDown } from '@lucide/vue'
import type { NavigationMenuTriggerProps } from 'reka-ui'
import { NavigationMenuTrigger, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { navigationMenuTriggerStyle } from './navigationMenuTriggerStyle'
const {
class: className,
active,
...restProps
} = defineProps<
NavigationMenuTriggerProps & {
class?: HTMLAttributes['class']
active?: boolean
}
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<NavigationMenuTrigger
data-slot="navigation-menu-trigger"
v-bind="forwardedProps"
:data-active="active ? '' : undefined"
:class="cn(navigationMenuTriggerStyle(), 'group', className)"
>
<span class="ppformula-text-center">
<slot />
</span>
<ChevronDown
class="relative ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { NavigationMenuViewportProps } from 'reka-ui'
import { NavigationMenuViewport, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuViewportProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
<NavigationMenuViewport
data-slot="navigation-menu-viewport"
v-bind="forwardedProps"
:class="
cn(
'origin-top-center bg-primary-comfy-ink-light border-primary-comfy-ink-light relative left-(--reka-navigation-menu-viewport-left) mt-1.5 h-(--reka-navigation-menu-viewport-height) w-full overflow-hidden rounded-3xl border text-primary-comfy-canvas shadow-sm data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-(--reka-navigation-menu-viewport-width)',
className
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,10 @@
import { cva } from 'class-variance-authority'
export const navigationMenuTriggerStyle = cva([
'group font-formula inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-2xl px-4 py-3 text-sm font-extrabold tracking-wider text-primary-comfy-canvas uppercase transition-[color,box-shadow] outline-none',
'hover:text-primary-warm-gray',
'data-[state=open]:hover:text-primary-comfy-yellow data-[state=open]:text-primary-comfy-yellow data-[state=open]:focus:text-primary-comfy-yellow',
'data-active:text-primary-comfy-yellow data-active:hover:text-primary-comfy-yellow',
'focus:bg-accent focus-visible:ring-primary-comfy-yellow focus:text-accent-foreground focus-visible:ring-3 focus-visible:outline-1',
'disabled:pointer-events-none disabled:opacity-50'
])

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="sheet-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { X } from '@lucide/vue'
import { DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import SheetClose from './SheetClose.vue'
import SheetOverlay from './SheetOverlay.vue'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: 'top' | 'right' | 'bottom' | 'left'
closeLabel: string
}
defineOptions({
inheritAttrs: false
})
const {
side = 'right',
closeLabel,
class: classProp,
...delegatedProps
} = defineProps<SheetContentProps>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
cn(
'fixed z-50 flex flex-col gap-4 bg-primary-comfy-ink transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'top' &&
'inset-x-0 top-0 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
classProp
)
"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<SheetClose
class="focus:ring-primary-comfy-yellow/50 text-primary-comfy-yellow border-primary-comfy-yellow absolute top-4 right-4 rounded-xl border p-2 ring-offset-primary-comfy-ink transition-opacity focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-6" />
<span class="sr-only">{{ closeLabel }}</span>
</SheetClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-primary-warm-gray text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="
cn(
'bg-transparency-white-t4 fixed inset-0 z-50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
props.class
)
"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogTitleProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-primary-warm-white font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,32 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
export function useCurrentPath() {
const currentPath = ref('')
function update() {
currentPath.value = window.location.pathname
}
onMounted(() => {
update()
document.addEventListener('astro:page-load', update)
window.addEventListener('popstate', update)
})
onBeforeUnmount(() => {
document.removeEventListener('astro:page-load', update)
window.removeEventListener('popstate', update)
})
return currentPath
}
export function isHrefActive(href: string, currentPath: string): boolean {
if (!href || !currentPath || href.startsWith('http')) return false
const path = href.split('#')[0].split('?')[0]
if (!path) return false
function norm(s: string) {
return s.length > 1 ? s.replace(/\/$/, '') : s
}
return norm(path) === norm(currentPath)
}

View File

@@ -62,9 +62,12 @@ export const externalLinks = {
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -0,0 +1,193 @@
import { externalLinks, getRoutes } from '../config/routes'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
export type NavColumnItem = {
label: string
href: string
badge?: 'new'
external?: boolean
}
export type NavColumn = {
header: string
items: NavColumnItem[]
}
export type NavFeatured = {
imageSrc: string
imageAlt?: string
title: string
cta: {
label: string
ariaLabel?: string
href: string
}
}
export type NavItem =
| {
label: string
columns: NavColumn[]
featured?: NavFeatured
href?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
}
},
columns: [
{
header: t('nav.products', 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.comfyEnterprise', locale),
href: routes.cloudEnterprise
}
]
},
{
header: t('nav.colFeatures', locale),
items: [
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.docs', locale),
href: externalLinks.docs,
external: true
}
]
}
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),
title: t('nav.featuredCommunityTitle', locale),
cta: {
label: t('cta.watchDemo', locale),
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
}
},
columns: [
{
header: t('nav.colPrograms', 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,
badge: 'new'
}
]
},
{
header: t('nav.colConnect', locale),
items: [
{
label: t('nav.discord', locale),
href: externalLinks.discord,
external: true
},
{
label: t('nav.github', locale),
href: externalLinks.github,
external: true
},
{
label: t('nav.youtube', locale),
href: externalLinks.youtube,
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,
external: true
}
]
}
]
},
{
label: t('nav.company', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
imageAlt: t('nav.featuredCompanyAlt', locale),
title: t('nav.featuredCompanyTitle', locale),
cta: {
label: t('cta.watchNow', locale),
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
href: '/customers#hero-video'
}
},
columns: [
{
header: t('nav.company', 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 }
]
},
{
header: t('nav.colMore', locale),
items: [
{
label: t('nav.customerStories', locale),
href: routes.customers
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
external: true
}
]
}
]
}
]
}

View File

@@ -16,6 +16,14 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
},
'cta.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// HeroSection
'hero.title': {
@@ -1843,10 +1851,69 @@ const translations = {
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
'nav.ctaDesktopCore': { en: 'DESKTOP', 'zh-CN': '桌面版' },
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
'nav.mobileMenuDescription': {
en: 'Site navigation and quick links',
'zh-CN': '网站导航和快速链接'
},
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
'nav.colMore': { en: 'More', 'zh-CN': '更多' },
// Dropdown items not yet covered above
'nav.reddit': { en: 'Reddit', 'zh-CN': 'Reddit' },
'nav.x': { en: 'X', 'zh-CN': 'X' },
'nav.instagram': { en: 'Instagram', 'zh-CN': 'Instagram' },
'nav.affiliates': { en: 'Affiliates', 'zh-CN': '联盟计划' },
'nav.contact': { en: 'Contact', 'zh-CN': '联系我们' },
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布Seedance 2.0'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'nav.featuredCommunityAlt': {
en: 'Sky Replacement workflow demo image',
'zh-CN': '天空替换工作流演示图片'
},
'nav.featuredCommunityCtaAria': {
en: 'Watch the Sky Replacement demo',
'zh-CN': '观看天空替换演示'
},
'nav.featuredCompanyTitle': {
en: 'Customer story: Black Math',
'zh-CN': '客户故事Black Math'
},
'nav.featuredCompanyAlt': {
en: 'Black Math customer story image',
'zh-CN': 'Black Math 客户故事图片'
},
'nav.featuredCompanyCtaAria': {
en: 'Watch the Black Math customer story',
'zh-CN': '观看 Black Math 客户故事'
},
// SiteFooter
'footer.tagline': {

View File

@@ -4,7 +4,7 @@ import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -137,7 +137,7 @@ const websiteJsonLd = {
</noscript>
)}
<SiteNav locale={locale} github-stars={githubStars} client:load />
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<slot />
</main>

View File

@@ -1,20 +1,30 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/home/ProductCardsSection.vue'
import HeroSection from '../components/home/HeroSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
import { t } from '../i18n/translations'
import BaseLayout from "../layouts/BaseLayout.astro";
import ProductCardsSection from "../components/home/ProductCardsSection.vue";
import HeroSection from "../components/home/HeroSection.vue";
import SocialProofBarSection from "../components/common/SocialProofBarSection.vue";
import ProductShowcaseSection from "../components/home/ProductShowcaseSection.vue";
import UseCaseSection from "../components/home/UseCaseSection.vue";
import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSection.vue";
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t('hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
description={t("hero.subtitle", "en")}
keywords={[
"comfyui app",
"comfyui web app",
"comfy ui application",
"comfyui application",
"comfy app",
"comfyui",
"visual ai app",
"node-based ai",
"generative ai workflows",
]}
>
<HeroSection client:load />
<SocialProofBarSection />

View File

@@ -1,6 +1,16 @@
@import 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';
@import 'tw-animate-css';
/* PP Formula's native vertical metrics place the baseline too high, so caps
sit in the upper half of the line box. Overriding ascent/descent re-anchors
the baseline so caps render optically centered when line-height is tight.
BUT, IT IS NOT WELL SUPPORTED:
ascent-override: 92%;
descent-override: 8%;
line-gap-override: 0%; */
@font-face {
font-family: 'PP Formula';
src: url('/fonts/PPFormula-Light.woff2') format('woff2');
@@ -53,10 +63,12 @@
--color-site-dropdown: #332b38;
--color-primary-comfy-yellow: #f2ff59;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-ink-light: #2a2330;
--color-primary-comfy-canvas: #c2bfb9;
--color-primary-warm-white: #f0efed;
--color-primary-warm-gray: #7e7c78;
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
@@ -100,6 +112,7 @@
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
@@ -109,6 +122,7 @@
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
}
@@ -131,9 +145,11 @@
transform: scale(1);
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: scale(1.75);
opacity: 0;
@@ -174,6 +190,7 @@
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}

View File

@@ -69,6 +69,24 @@ export class TemplateHelper {
}
async mockIndex(): Promise<void> {
const customTemplatesHandler = async (route: Route) => {
const customTemplates: Record<string, string[]> = {}
await route.fulfill({
status: 200,
body: JSON.stringify(customTemplates),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const customTemplatesPattern = '**/api/workflow_templates'
this.routeHandlers.push({
pattern: customTemplatesPattern,
handler: customTemplatesHandler
})
await this.page.route(customTemplatesPattern, customTemplatesHandler)
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON

View File

@@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await dialog.getByTestId('pointer-zone').hover()
await dialog.getByText('Brush Settings').hover()
await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0')
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -4,7 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
test.describe('Locale-specific documentation', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
})
// Set locale to Japanese
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -434,9 +382,7 @@ This is English documentation.
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {

View File

@@ -10,13 +10,16 @@ import {
} from '@e2e/fixtures/utils/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
test.describe('Widget rendering', () => {
test('Node enforces minimum size', async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
@@ -28,17 +31,15 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(size![1]).toBeGreaterThanOrEqual(550)
})
test('Width, height, and bg_color standard widgets are hidden', async ({
test('Does not render hidden standard widgets in Vue mode', async ({
comfyPage
}) => {
const hiddenFlags = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
return (node?.widgets ?? [])
.filter((w) => ['width', 'height', 'bg_color'].includes(w.name))
.map((w) => w.options.hidden ?? false)
})
expect(hiddenFlags).toEqual([true, true, true])
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
}
})
})
@@ -788,6 +789,49 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test.describe(
'Painter legacy LiteGraph rendering',
{ tag: ['@widget', '@canvas'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
comfyPage
}) => {
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
expect(painterNodes).toHaveLength(1)
const painterNode = painterNodes[0]!
const maskWidget = await painterNode.getWidgetByName('mask')
const maskWidgetClientPosition = await maskWidget.getPosition()
const widgetRowClientHeight = await comfyPage.page.evaluate(
() =>
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
window.app!.canvas.ds.scale
)
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeHidden()
for (const [
index,
widgetName
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
await test.step(`Click ${widgetName} row`, async () => {
await comfyPage.page.mouse.click(
maskWidgetClientPosition.x,
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
)
await comfyPage.nextFrame()
await expect(legacyPrompt).toBeHidden()
})
}
})
}
)
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },

View File

@@ -114,6 +114,25 @@ test.describe(
.poll(() => collectWidgetLabels(shownSection))
.not.toContain('text')
})
test(
'control after generate does not display',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
const { editor } = comfyPage.subgraph
await editor.ensureOpen(subgraphNode)
await expect(
editor.promotionItems.first(),
'some promotion item is visible'
).toBeVisible()
const widgetName = 'control_after_generate'
await expect(editor.resolveItem({ widgetName })).toBeHidden()
}
)
})
test.describe('Parameters tab (WidgetActions menu)', () => {

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Regression for #12840: a free-tier paywall on queue (`POST /prompt` 402 with
// `{ error: { type: 'PAYMENT_REQUIRED', message: 'Subscription required to
// queue workflows' } }`) is an account precondition. It must open its own modal
// and stay out of the error panel, instead of surfacing the raw backend string
// with non-actionable Find-on-GitHub / Copy actions.
test.describe('Subscription paywall on queue', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
async function mockQueueError(page: Page, error: PromptResponse['error']) {
const body: PromptResponse = { node_errors: {}, error }
await page.route('**/api/prompt', async (route) => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
}
test('keeps the subscription paywall out of the error panel', async ({
comfyPage
}) => {
await mockQueueError(comfyPage.page, {
type: 'PAYMENT_REQUIRED',
message: 'Subscription required to queue workflows',
details: ''
})
const queued = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.actionbar.queueButton.primaryButton.click()
await queued
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
})
test('still surfaces ordinary queue errors in the error panel', async ({
comfyPage
}) => {
await mockQueueError(comfyPage.page, {
type: 'server_error',
message: 'The server exploded',
details: ''
})
const queued = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.actionbar.queueButton.primaryButton.click()
await queued
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
})

View File

@@ -1,13 +1,13 @@
import { expect, mergeTests } from '@playwright/test'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const test = mergeTests(createCloudAssetsFixture([]), templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -420,7 +420,6 @@ export default defineConfig([
'@intlify/vue-i18n/no-raw-text': 'off'
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

@@ -60,6 +60,7 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",

View File

@@ -0,0 +1,91 @@
export interface ComfyDownloadProgress {
url: string
filename: string
directory?: string
progress: number
receivedBytes?: number
totalBytes?: number
speedBytesPerSec?: number
etaSeconds?: number
status:
| 'pending'
| 'downloading'
| 'paused'
| 'completed'
| 'error'
| 'cancelled'
error?: string
isImage?: boolean
}
export interface TerminalRestore {
buffer: string[]
size: { cols: number; rows: number }
exited: boolean
}
export interface LogsRestore {
installationId: string
buffer: string[]
}
export interface LogsOutputMsg {
installationId: string
text: string
}
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
export type ComfyDesktop2TelemetryProperties = Record<
string,
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
write(data: string, installationId?: string): Promise<void>
resize(cols: number, rows: number, installationId?: string): Promise<void>
restart(installationId?: string): Promise<TerminalRestore>
openPopout(): Promise<void>
onOutput(callback: (data: string) => void): () => void
onExited(callback: () => void): () => void
}
export interface ComfyDesktop2LogsBridge {
subscribe(installationId?: string): Promise<LogsRestore>
unsubscribe(installationId?: string): Promise<void>
openPopout(): Promise<void>
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
}
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
}
export interface ComfyDesktop2Bridge {
isRemote(): boolean
downloadModel?: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
downloadAsset?: (
url: string,
filename: string,
authToken?: string
) => Promise<boolean>
pauseDownload?: (url: string) => Promise<boolean>
resumeDownload?: (url: string) => Promise<boolean>
cancelDownload?: (url: string) => Promise<boolean>
onDownloadProgress?: (
callback: (data: ComfyDownloadProgress) => void
) => () => void
reportTheme?: (bg: string, text: string) => void
Terminal?: ComfyDesktop2TerminalBridge
Logs?: ComfyDesktop2LogsBridge
Telemetry?: ComfyDesktop2TelemetryBridge
}
export type ComfyDesktop2BridgeImplementation = {
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
}

View File

@@ -0,0 +1 @@
export * from './comfyDesktopBridge.js'

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,27 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
"author": {
"name": "Comfy Org",
"email": "support@comfy.org",
"url": "https://www.comfy.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
},
"files": [
"comfyDesktopBridge.d.ts",
"index.d.ts",
"index.js"
],
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "public"
}
}

View File

@@ -102,6 +102,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('sound.wav')).toBe('audio')
expect(getMediaTypeFromFilename('music.ogg')).toBe('audio')
expect(getMediaTypeFromFilename('audio.flac')).toBe('audio')
expect(getMediaTypeFromFilename('music.opus')).toBe('audio')
})
})

View File

@@ -590,7 +590,7 @@ const IMAGE_EXTENSIONS = [
'svg'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'opus'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',

39
pnpm-lock.yaml generated
View File

@@ -51,6 +51,9 @@ catalogs:
'@lobehub/i18n-cli':
specifier: ^1.26.1
version: 1.26.1
'@lucide/vue':
specifier: ^1.17.0
version: 1.18.0
'@pinia/testing':
specifier: ^1.0.3
version: 1.0.3
@@ -186,6 +189,9 @@ catalogs:
axios:
specifier: ^1.15.2
version: 1.16.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
@@ -429,6 +435,9 @@ importers:
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-desktop-bridge-types':
specifier: workspace:*
version: link:packages/comfyui-desktop-bridge-types
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
@@ -947,12 +956,18 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:../../packages/tailwind-utils
'@lucide/vue':
specifier: 'catalog:'
version: 1.18.0(vue@3.5.34(typescript@5.9.3))
'@vercel/analytics':
specifier: 'catalog:'
version: 2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.34(typescript@5.9.3))
class-variance-authority:
specifier: 'catalog:'
version: 0.7.1
cva:
specifier: 'catalog:'
version: 1.0.0-beta.4(typescript@5.9.3)
@@ -965,6 +980,9 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
reka-ui:
specifier: 'catalog:'
version: 2.5.0(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
three:
specifier: 'catalog:'
version: 0.184.0
@@ -996,6 +1014,9 @@ importers:
tsx:
specifier: 'catalog:'
version: 4.19.4
tw-animate-css:
specifier: 'catalog:'
version: 1.3.8
typescript:
specifier: 'catalog:'
version: 5.9.3
@@ -1003,6 +1024,8 @@ importers:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
packages/comfyui-desktop-bridge-types: {}
packages/design-system:
dependencies:
'@iconify-json/lucide':
@@ -2397,6 +2420,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lucide/vue@1.18.0':
resolution: {integrity: sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==}
peerDependencies:
vue: '>=3.0.1'
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
@@ -4852,6 +4880,9 @@ packages:
citty@0.2.1:
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clean-css@5.3.3:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
@@ -10535,6 +10566,10 @@ snapshots:
- ws
- zod
'@lucide/vue@1.18.0(vue@3.5.34(typescript@5.9.3))':
dependencies:
vue: 3.5.34(typescript@5.9.3)
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
@@ -13011,6 +13046,10 @@ snapshots:
citty@0.2.1: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
clean-css@5.3.3:
dependencies:
source-map: 0.6.1

View File

@@ -25,6 +25,7 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@lobehub/i18n-cli': ^1.26.1
'@lucide/vue': ^1.17.0
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -71,6 +72,7 @@ catalog:
algoliasearch: ^5.21.0
astro: ^6.4.2
axios: ^1.15.2
class-variance-authority: ^0.7.1
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
@@ -165,7 +167,7 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
#Security overrides
# Security overrides
lodash: ^4.18.0
yaml: ^2.8.3
minimatch@^9.0.0: ^9.0.7

View File

@@ -2,6 +2,12 @@ import fs from 'fs'
import path from 'path'
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
const desktopBridgeTypesPackage = JSON.parse(
fs.readFileSync(
'./packages/comfyui-desktop-bridge-types/package.json',
'utf8'
)
)
// Create the types-only package.json
const typesPackage = {
@@ -16,7 +22,9 @@ const typesPackage = {
homepage: mainPackage.homepage,
description: `TypeScript definitions for ${mainPackage.name}`,
license: mainPackage.license,
dependencies: {},
dependencies: {
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
},
peerDependencies: {
vue: mainPackage.dependencies.vue,
zod: mainPackage.dependencies.zod
@@ -34,5 +42,3 @@ fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(typesPackage, null, 2)
)
console.log('Types package.json have been prepared in the dist directory')

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'

View File

@@ -5,6 +5,7 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import {
demotePromotedInput,
demoteWidget,
@@ -53,6 +54,7 @@ const canvasStore = useCanvasStore()
const previewExposureStore = usePreviewExposureStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
@@ -175,9 +177,13 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
return interiorWidgets.value.filter(
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
)
return interiorWidgets.value
.filter(([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`))
.filter(
([, w]) =>
w.name.startsWith('$$') ||
!(w.options.canvasOnly && shouldRenderVueNodes.value)
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()

View File

@@ -1,28 +1,8 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
import type { AppMode } from '@/utils/appMode'
const enableAppBuilder = ref(true)

View File

@@ -4,6 +4,7 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -85,6 +86,7 @@ export function useCoreCommands(): ComfyCommand[] {
const executionStore = useExecutionStore()
const modelStore = useModelStore()
const telemetry = useTelemetry()
const { trackRunButton } = useRunButtonTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
@@ -499,7 +501,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -522,7 +524,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -544,7 +546,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false },
telemetry: {
trackRunButton: vi.fn()
},
executionContext: {
is_template: false,
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: []
},
executionContextError: null as Error | null
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: state.mode,
isAppMode: state.isAppMode
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => state.telemetry
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => {
if (state.executionContextError) throw state.executionContextError
return state.executionContext
}
}))
import {
getRunButtonTelemetryProperties,
useRunButtonTelemetry
} from './useRunButtonTelemetry'
describe('useRunButtonTelemetry', () => {
beforeEach(() => {
localStorage.clear()
state.telemetry.trackRunButton.mockClear()
state.mode.value = 'graph'
state.isAppMode.value = false
state.executionContextError = null
})
it('builds run button properties from workspace state', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
expect(
getRunButtonTelemetryProperties({
subscribe_to_run: true,
trigger_source: 'button'
})
).toEqual({
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
})
})
it('tracks the completed run button payload', () => {
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
subscribe_to_run: false,
trigger_source: 'linear',
workflow_name: 'Desktop workflow'
})
)
})
it('does not throw when run button context collection fails', () => {
const error = new Error('Context unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
state.executionContextError = error
try {
expect(() =>
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
).not.toThrow()
expect(state.telemetry.trackRunButton).not.toHaveBeenCalled()
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
'[Telemetry] Run button tracking failed',
error
)
} finally {
consoleError.mockRestore()
}
})
})

View File

@@ -0,0 +1,52 @@
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import type {
ExecutionTriggerSource,
RunButtonProperties
} from '@/platform/telemetry/types'
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
export type RunButtonTelemetryOptions = {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}
export function getRunButtonTelemetryProperties(
options?: RunButtonTelemetryOptions
): RunButtonProperties {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
return {
subscribe_to_run: options?.subscribe_to_run ?? false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
}
export function useRunButtonTelemetry() {
function trackRunButton(options?: RunButtonTelemetryOptions): void {
const telemetry = useTelemetry()
if (!telemetry) return
try {
telemetry.trackRunButton(getRunButtonTelemetryProperties(options))
} catch (error) {
console.error('[Telemetry] Run button tracking failed', error)
}
}
return { trackRunButton }
}

View File

@@ -1,26 +0,0 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
/**
* Cloud-only extension that enforces active subscription requirement
*/
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Subscription',
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useBillingContext()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return
void requireActiveSubscription()
}
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
immediate: true
})
}
})

View File

@@ -36,10 +36,6 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')
}
}
// Feedback button for cloud and nightly builds

View File

@@ -14,6 +14,7 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
object: THREE.Camera
domElement: HTMLElement
enableDamping = false
enabled = true
target = new THREE.Vector3()
update = vi.fn()
dispose = vi.fn()
@@ -165,4 +166,24 @@ describe('ControlsManager', () => {
expect(manager.controls.dispose).toHaveBeenCalled()
})
})
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
expect(manager.controls.enabled).toBe(false)
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.detach()
manager.attach()
expect(manager.controls.enabled).toBe(true)
})
})
})

View File

@@ -63,6 +63,15 @@ export class ControlsManager implements ControlsManagerInterface {
camera.position.copy(position)
this.controls.update()
}
detach(): void {
this.controls.enabled = false
}
attach(): void {
this.controls.enabled = true
}
reset(): void {
this.controls.target.set(0, 0, 0)
this.controls.update()

View File

@@ -2,26 +2,19 @@ import * as THREE from 'three'
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { AnimationManager } from './AnimationManager'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
import type { RecordingManager } from './RecordingManager'
import type { SceneManager } from './SceneManager'
import type { SceneModelManager } from './SceneModelManager'
import type { ViewHelperManager } from './ViewHelperManager'
import { Viewport3d, type Viewport3dDeps } from './Viewport3d'
import { computeCameraFromMatrices } from './cameraFromMatrices'
import { DIRECT_EXPORT_FORMATS } from './constants'
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
LoadModelOptions,
@@ -29,20 +22,10 @@ import type {
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Load3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
export type Load3dDeps = Viewport3dDeps & {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -70,22 +53,8 @@ function positionThumbnailCamera(
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
class Load3d extends Viewport3d {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -93,19 +62,8 @@ class Load3d {
gizmoManager: GizmoManager
adapterRef: AdapterRef
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private hasLoadedModel: boolean = false
constructor(
@@ -113,26 +71,9 @@ class Load3d {
deps: Load3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
super(container, deps, options)
if (options.width && options.height) {
this.targetWidth = options.width
this.targetHeight = options.height
this.targetAspectRatio = options.width / options.height
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.hdriManager = deps.hdriManager
this.viewHelperManager = deps.viewHelperManager
this.loaderManager = deps.loaderManager
this.modelManager = deps.modelManager
this.recordingManager = deps.recordingManager
@@ -140,35 +81,16 @@ class Load3d {
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
this.start()
}
private async repaintWhenSparkPaintable(): Promise<void> {
@@ -178,49 +100,14 @@ class Load3d {
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
@@ -229,119 +116,12 @@ class Load3d {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
private shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
protected override tickPerFrame(delta: number): void {
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
super.tickPerFrame(delta)
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
override isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
@@ -444,9 +224,27 @@ class Load3d {
return ModelExporter.detectFormatFromURL(url)
}
protected override onActiveCameraChanged(): void {
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
@@ -473,7 +271,6 @@ class Load3d {
height
)
} else {
// No aspect ratio constraints: fill container
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
@@ -488,12 +285,6 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
@@ -502,39 +293,6 @@ class Load3d {
this.forceRender()
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
@@ -553,19 +311,15 @@ class Load3d {
this.setFOV(fovYDegrees)
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
/**
* Monotonic counter that ticks once per loadModel call, **before** any
* await. Callers can capture this immediately after triggering a load and
* later compare against `currentLoadGeneration` to verify their load is
* still the latest one — useful when chaining post-load work
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
* otherwise wait for any newer queued load and apply stale state to it.
*/
get currentLoadGeneration(): number {
return this._loadGeneration
}
@@ -606,8 +360,6 @@ class Load3d {
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
@@ -623,7 +375,6 @@ class Load3d {
await this.loaderManager.loadModel(url, originalFileName, options)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
@@ -633,7 +384,6 @@ class Load3d {
}
if (savedCameraState) {
// setupForModel runs during loadModel and clobbers the camera; restore on top.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
@@ -674,11 +424,6 @@ class Load3d {
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
@@ -706,73 +451,10 @@ class Load3d {
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
emitModelReady(): void {
this.eventManager.emitEvent('modelReady', null)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
captureScene(width: number, height: number): Promise<CaptureResult> {
this.gizmoManager.removeFromScene()
@@ -818,7 +500,6 @@ class Load3d {
this.recordingManager.clearRecording()
}
// Animation methods
public setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
@@ -977,41 +658,15 @@ class Load3d {
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
protected override disposeManagers(): void {
super.disposeManagers()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.adapterRef.current = null
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
}
}

View File

@@ -0,0 +1,433 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
type CameraStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setFOV: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneStub = {
captureScene: ReturnType<typeof vi.fn>
setBackgroundColor: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
removeBackgroundImage: ReturnType<typeof vi.fn>
toggleGrid: ReturnType<typeof vi.fn>
setBackgroundRenderMode: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
renderBackground: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
updateBackgroundSize: ReturnType<typeof vi.fn>
backgroundTexture: unknown
backgroundMesh: unknown
scene: THREE.Scene
}
function makeViewportInstance() {
const cameraManager: CameraStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
getCameraState: vi.fn(() => ({
position: new THREE.Vector3(),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective' as const
})),
setCameraState: vi.fn(),
setFOV: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
captureScene: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
removeBackgroundImage: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundRenderMode: vi.fn(),
handleResize: vi.fn(),
renderBackground: vi.fn(),
dispose: vi.fn(),
updateBackgroundSize: vi.fn(),
backgroundTexture: null,
backgroundMesh: null,
scene: new THREE.Scene()
}
const controlsManager = {
updateCamera: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
reset: vi.fn(),
detach: vi.fn(),
attach: vi.fn()
}
const lightingManager = {
setLightIntensity: vi.fn(),
dispose: vi.fn()
}
const viewHelperManager = {
recreateViewHelper: vi.fn(),
update: vi.fn(),
visibleViewHelper: vi.fn(),
viewHelper: { render: vi.fn() },
dispose: vi.fn()
}
const eventManager = {
emitEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const viewport = Object.create(Viewport3d.prototype) as Viewport3d
Object.assign(viewport, {
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
viewport,
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: viewport.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Viewport3d', () => {
let ctx: ReturnType<typeof makeViewportInstance>
beforeEach(() => {
ctx = makeViewportInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('camera delegation (model-independent)', () => {
it('toggleCamera updates controls and recreates view helper without touching model state', () => {
ctx.viewport.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
})
describe('isActive (no model concerns)', () => {
it('returns false when no mouse activity is present', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(ctx.viewport.isActive()).toBe(false)
})
it('does not consult recording or animation state — that is a Load3d concern', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(() => ctx.viewport.isActive()).not.toThrow()
})
})
describe('manager accessors', () => {
it('exposes managers through both fields and getters', () => {
expect(ctx.viewport.cameraManager).toBe(ctx.cameraManager)
expect(ctx.viewport.getCameraManager()).toBe(ctx.cameraManager)
expect(ctx.viewport.sceneManager).toBe(ctx.sceneManager)
expect(ctx.viewport.getSceneManager()).toBe(ctx.sceneManager)
expect(ctx.viewport.controlsManager).toBe(ctx.controlsManager)
expect(ctx.viewport.getControlsManager()).toBe(ctx.controlsManager)
expect(ctx.viewport.lightingManager).toBe(ctx.lightingManager)
expect(ctx.viewport.getLightingManager()).toBe(ctx.lightingManager)
expect(ctx.viewport.viewHelperManager).toBe(ctx.viewHelperManager)
expect(ctx.viewport.getViewHelperManager()).toBe(ctx.viewHelperManager)
expect(ctx.viewport.eventManager).toBe(ctx.eventManager)
expect(ctx.viewport.getEventManager()).toBe(ctx.eventManager)
})
})
describe('POV swap (setExternalActiveCamera)', () => {
it('getRenderCamera returns the orbit camera when no external camera is set', () => {
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('getRenderCamera returns the external camera once installed', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.viewport.getRenderCamera()).toBe(subjectCamera)
})
it('installing an external camera detaches controls and hides the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(ctx.forceRender).toHaveBeenCalled()
})
it('clearing the external camera re-attaches controls and shows the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.controlsManager.attach.mockClear()
ctx.viewHelperManager.visibleViewHelper.mockClear()
ctx.viewport.setExternalActiveCamera(null)
expect(ctx.controlsManager.attach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(true)
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('setting the same external camera twice is a no-op', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).not.toHaveBeenCalled()
})
})
describe('overlay', () => {
function makeOverlay() {
return {
attach: vi.fn(),
detach: vi.fn(),
update: vi.fn(),
onActiveCameraChange: vi.fn(),
dispose: vi.fn()
}
}
it('setOverlay attaches to the scene and notifies of the current render camera', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
expect(overlay.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewport.getOverlay()).toBe(overlay)
})
it('replacing an overlay detaches and disposes the prior one', () => {
const first = makeOverlay()
const second = makeOverlay()
ctx.viewport.setOverlay(first)
ctx.viewport.setOverlay(second)
expect(first.detach).toHaveBeenCalledOnce()
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(ctx.viewport.getOverlay()).toBe(second)
})
it('removeOverlay detaches and disposes the installed overlay', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
ctx.viewport.removeOverlay()
expect(overlay.detach).toHaveBeenCalledOnce()
expect(overlay.dispose).toHaveBeenCalledOnce()
expect(ctx.viewport.getOverlay()).toBeNull()
})
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
const tick = (
ctx.viewport as unknown as {
tickPerFrame(delta: number): void
}
).tickPerFrame.bind(ctx.viewport)
tick(0.016)
expect(overlay.update).toHaveBeenCalledWith(0.016)
expect(ctx.viewHelperManager.update).toHaveBeenCalledWith(0.016)
expect(ctx.controlsManager.update).toHaveBeenCalledOnce()
})
it('toggleCamera notifies the overlay with the new orbit camera (when no POV)', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
})
it('toggleCamera does NOT notify the overlay while a POV camera is active', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
ctx.viewport.setExternalActiveCamera(subjectCamera)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).not.toHaveBeenCalled()
})
it('setExternalActiveCamera notifies the overlay with the new render camera', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(subjectCamera)
})
})
describe('applyTargetSize guards', () => {
function applyTargetSize(width: number, height: number): void {
;(
ctx.viewport as unknown as {
applyTargetSize(w: number, h: number): void
}
).applyTargetSize(width, height)
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 0,
targetHeight: 0,
targetAspectRatio: 1
})
})
it('writes width / height / aspect when both inputs are positive finite', () => {
applyTargetSize(800, 400)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it.for([
['zero width', 0, 100],
['zero height', 100, 0],
['negative width', -100, 100],
['negative height', 100, -100],
['NaN width', Number.NaN, 100],
['Infinity height', 100, Number.POSITIVE_INFINITY]
] as const)('rejects %s without touching prior state', ([, w, h]) => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
applyTargetSize(w, h)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it('setTargetSize routes through the guard', () => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
ctx.viewport.setTargetSize(0, 0)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.assign(ctx.viewport, {
hasStarted: false,
initialRenderTimer: null,
startAnimation: vi.fn(),
renderLoop: { stop: vi.fn() },
resizeObserver: null,
disposeContextMenuGuard: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: Object.assign(document.createElement('canvas'), {
remove: vi.fn()
})
},
disposeManagers: vi.fn()
})
})
afterEach(() => {
vi.useRealTimers()
})
it('start schedules a deferred forceRender and remove clears it before the timer fires', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
ctx.viewport.remove()
vi.advanceTimersByTime(500)
expect(ctx.forceRender).not.toHaveBeenCalled()
})
it('the deferred forceRender does fire when remove is not called', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
vi.advanceTimersByTime(100)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
})

Some files were not shown because too many files have changed in this diff Show More