Compare commits

..

10 Commits

Author SHA1 Message Date
pythongosssss
cfc7525384 fix test 2026-06-19 08:19:52 -07:00
pythongosssss
347a126316 feedback: add toast on missing widget & track correct source 2026-06-18 08:08:11 -07:00
pythongosssss
6641346070 Merge remote-tracking branch 'origin/main' into pysssss/model-asset-ghost-node
# Conflicts:
#	src/platform/assets/utils/createModelNodeFromAsset.test.ts
2026-06-18 06:39:06 -07:00
pythongosssss
230dfcd32c test coverage 2026-06-11 03:06:00 -07:00
pythongosssss
7e2826bf7a rabbit 2026-06-10 13:01:36 -07:00
pythongosssss
307e8a0d04 fix tests 2026-06-10 12:34:13 -07:00
pythongosssss
e2c9550664 refactor to use useMouse + rAF sampling for the drag ghost
- replace pointer tracking with once per frame transform
2026-06-10 09:02:49 -07:00
pythongosssss
a87e107b01 remove double pointermove 2026-06-10 08:19:14 -07:00
pythongosssss
c9f1cc42ad remove unnecessary drag listener 2026-06-10 07:47:30 -07:00
pythongosssss
6cc1f20bd4 feat: show node preview when adding models from dialog & sidebar
- split createModelNodeFromAsset into resolveModelNodeFromAsset + startModelNodeDragFromAsset
  - move NodeDragPreview from the node library tab to GraphCanvas; drag listeners attach on startDrag instead of tab mount
  - useNodeDragToCanvas: options-object startDrag, single previewPosition export, widget values applied on placement, toast + log on add failure
  - track last pointer position passively so the ghost appears at the arming click without waiting for mouse movement
  - LGraphNodePreview: accept widgetValues and lead combo options with the value so the ghost shows the model filename
  - e2e: cover sidebar click-to-place and asset browser "Use" flows
2026-06-10 06:04:16 -07:00
241 changed files with 6283 additions and 7465 deletions

View File

@@ -15,11 +15,6 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -29,5 +29,3 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

@@ -47,8 +47,6 @@ jobs:
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 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: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -25,15 +25,12 @@
"@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:"
@@ -46,7 +43,6 @@
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3062_2148)">
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
</g>
<defs>
<clipPath id="clip0_3062_2148">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,17 @@
<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="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<aside class="scrollbar-none hidden 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="mb-6 text-2xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
>
{{ t(key(section.id, 'title'), locale) }}
</h2>
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
<!-- Paragraph -->
<p
v-if="block.type === 'paragraph'"
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
v-html="t(key(section.id, `block.${i}`), locale)"
/>
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
locale
).split('\n')"
:key="j"
class="flex items-start gap-2 text-primary-comfy-canvas"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<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="flex items-start gap-3 text-primary-comfy-canvas"
class="text-primary-comfy-canvas flex items-start gap-3"
>
<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="mt-3 text-xs text-primary-comfy-canvas">
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
{{ t(key(section.id, `block.${i}.caption`), locale) }}
</figcaption>
</figure>
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
"
>
<p
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
class="text-primary-comfy-canvas text-lg/relaxed font-light 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="mt-2 text-sm font-semibold text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name`), locale) }}
</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
{{ t(key(section.id, `block.${i}.role`), locale) }}
</p>
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name2`), locale) }}
</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
{{ t(key(section.id, `block.${i}.role2`), locale) }}
</p>
</template>

View File

@@ -1,85 +0,0 @@
<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

@@ -1,76 +0,0 @@
<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

@@ -1,167 +0,0 @@
<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

@@ -1,36 +0,0 @@
<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

@@ -1,31 +0,0 @@
<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

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

View File

@@ -0,0 +1,82 @@
<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

@@ -0,0 +1,186 @@
<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

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

View File

@@ -0,0 +1,84 @@
<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

@@ -0,0 +1,262 @@
<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

@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import PlayPauseButton from './PlayPauseButton.vue'
export type VideoTrack = {
type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
@@ -35,7 +35,7 @@ const {
locale?: Locale
src?: string
poster?: string
tracks?: readonly VideoTrack[]
tracks?: VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,110 @@
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

@@ -0,0 +1,116 @@
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="mt-4 text-4xl/tight font-light text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
>
{{ t('customers.hero.heading', locale) }}
</h1>
<p
ref="bodyRef"
class="mt-6 max-w-lg text-base text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-6 max-w-lg text-base"
>
{{ t('customers.hero.body', locale) }}
</p>
@@ -72,12 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<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"
>
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 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="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light 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="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light 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-xl font-light text-primary-comfy-canvas lg:text-2xl">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ title }}
</h3>
<a :href="href" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider 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="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light 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-xl font-light text-primary-comfy-canvas lg:text-2xl">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ nextTitle }}
</h3>
<a :href="nextHref" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
</span>

View File

@@ -1,20 +0,0 @@
<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 '../ui/badge/Badge.vue'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'

View File

@@ -64,7 +64,6 @@ onUnmounted(() => {
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
:tracks="tutorial.caption"
autoplay
class="w-full"
/>

View File

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

View File

@@ -68,8 +68,7 @@ const plans: PricingPlan[] = [
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' },
{ text: 'pricing.plan.standard.feature3' }
{ text: 'pricing.plan.standard.feature2' }
]
},
{
@@ -123,11 +122,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="mt-3 text-base text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-base">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
@@ -157,7 +156,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -173,18 +172,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Summary -->
<p class="px-6 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas px-6 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
@@ -193,7 +192,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="px-6 text-sm text-primary-comfy-canvas"
class="text-primary-comfy-canvas px-6 text-sm"
>
{{ t(plan.creditsKey, locale) }}
</p>
@@ -202,7 +201,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="px-6 text-xs text-primary-comfy-canvas/80"
class="text-primary-comfy-canvas/80 px-6 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -212,10 +211,17 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
@@ -223,7 +229,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
@@ -263,7 +269,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -281,13 +287,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="mt-2 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-2 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
@@ -295,25 +301,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas/55">
<span class="text-primary-comfy-canvas/55 text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="mt-2 text-xs text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-2 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -362,7 +368,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
>
<!-- Left side -->
<div
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
>
<div>
<span
@@ -371,11 +377,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="mt-3 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-sm">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
@@ -386,7 +392,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
{{ t('pricing.footnote', locale) }}
</p>
</section>

View File

@@ -54,11 +54,7 @@ const features: IncludedFeature[] = [
},
{
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description'
},
{
titleKey: 'pricing.included.feature12.title',
descriptionKey: 'pricing.included.feature12.description',
descriptionKey: 'pricing.included.feature11.description',
isComingSoon: true
}
]
@@ -69,10 +65,10 @@ const features: IncludedFeature[] = [
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
>
<h2
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
>
{{ t('pricing.included.heading', locale) }}
</h2>
@@ -85,7 +81,7 @@ const features: IncludedFeature[] = [
:key="feature.titleKey"
:class="
index < features.length - 1
? 'border-b border-solid border-primary-comfy-canvas/15'
? 'border-primary-comfy-canvas/15 border-b border-solid'
: ''
"
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
@@ -103,14 +99,14 @@ const features: IncludedFeature[] = [
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
/>
<p class="text-sm font-medium text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->
<p
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
v-html="t(feature.descriptionKey, locale)"
/>
</div>

View File

@@ -1,40 +0,0 @@
<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

@@ -1,24 +0,0 @@
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,65 +0,0 @@
<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

@@ -1,94 +0,0 @@
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,61 +0,0 @@
<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

@@ -1,102 +0,0 @@
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

@@ -1,50 +0,0 @@
<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

@@ -1,31 +0,0 @@
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

@@ -1,45 +0,0 @@
<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

@@ -1,37 +0,0 @@
<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

@@ -1,21 +0,0 @@
<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

@@ -1,33 +0,0 @@
<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

@@ -1,29 +0,0 @@
<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

@@ -1,41 +0,0 @@
<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

@@ -1,29 +0,0 @@
<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

@@ -1,10 +0,0 @@
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

@@ -1,15 +0,0 @@
<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

@@ -1,12 +0,0 @@
<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

@@ -1,62 +0,0 @@
<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

@@ -1,23 +0,0 @@
<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

@@ -1,15 +0,0 @@
<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

@@ -1,28 +0,0 @@
<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

@@ -1,23 +0,0 @@
<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

@@ -1,12 +0,0 @@
<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

@@ -1,32 +0,0 @@
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,12 +62,9 @@ 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

@@ -1,4 +1,3 @@
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
@@ -8,7 +7,6 @@ export interface LearningTutorial {
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
@@ -30,14 +28,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
caption: [
{
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
@@ -48,15 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
caption: [
{
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -67,14 +49,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
caption: [
{
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -85,14 +59,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
caption: [
{
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -103,14 +69,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
caption: [
{
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -121,14 +79,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
caption: [
{
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,193 +0,0 @@
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,14 +16,6 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
},
'cta.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// HeroSection
'hero.title': {
@@ -1252,10 +1244,6 @@ const translations = {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.plan.standard.feature3': {
en: 'Run 1 workflow concurrently (via API)',
'zh-CN': '通过 API 并发运行 1 个工作流'
},
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
@@ -1284,8 +1272,8 @@ const translations = {
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Run up to 3 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 3 个工作流'
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
@@ -1312,8 +1300,8 @@ const translations = {
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: 'Run up to 5 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 5 个工作流'
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
@@ -1445,19 +1433,10 @@ const translations = {
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA打造专属风格。'
},
'pricing.included.feature11.title': {
en: 'Run Workflows via API',
'zh-CN': '通过 API 运行工作流'
},
'pricing.included.feature11.description': {
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
},
'pricing.included.feature12.title': {
en: 'Parallel job execution',
'zh-CN': '并行任务执行'
},
'pricing.included.feature12.description': {
'pricing.included.feature11.description': {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
@@ -1851,69 +1830,10 @@ 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 HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -137,7 +137,7 @@ const websiteJsonLd = {
</noscript>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<SiteNav locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<slot />
</main>

View File

@@ -1,30 +1,20 @@
---
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,16 +1,6 @@
@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');
@@ -63,12 +53,10 @@
--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;
@@ -112,7 +100,6 @@
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
@@ -122,7 +109,6 @@
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
}
@@ -145,11 +131,9 @@
transform: scale(1);
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: scale(1.75);
opacity: 0;
@@ -190,7 +174,6 @@
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}

View File

@@ -18,6 +18,7 @@ browser_tests/
│ ├── components/ - Page object classes (locators, user interactions)
│ │ ├── Actionbar.ts
│ │ ├── ContextMenu.ts
│ │ ├── ManageGroupNode.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ ├── Templates.ts
@@ -43,7 +44,7 @@ browser_tests/
### Architectural Separation
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `SettingDialog`).
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).

View File

@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator
header: Locator
constructor(
readonly page: Page,
readonly root: Locator
) {
this.footer = root.locator('footer')
this.header = root.locator('header')
}
async setLabel(name: string, label: string) {
const active = this.root.locator('.comfy-group-manage-node-page.active')
const input = active.getByPlaceholder(name)
await input.fill(label)
}
async save() {
await this.footer.getByText('Save').click()
}
async close() {
await this.footer.getByText('Close').click()
}
get selectedNodeTypeSelect(): Locator {
return this.header.locator('select').first()
}
async getSelectedNodeType() {
return await this.selectedNodeTypeSelect.inputValue()
}
async selectNode(name: string) {
const list = this.root.locator('.comfy-group-manage-list-items')
const item = list.getByText(name)
await item.click()
}
async changeTab(name: 'Inputs' | 'Widgets' | 'Outputs') {
const header = this.root.locator('.comfy-group-manage-node header')
const tab = header.getByText(name)
await tab.click()
}
}

View File

@@ -69,24 +69,6 @@ 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 '@/utils/appMode'
import type { AppMode } from '@/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -524,6 +525,14 @@ export class NodeReference {
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window.LiteGraph!['NODE_TITLE_HEIGHT']

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('Use button ghost-places a loader populated with the model', async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const card = comfyPage.page.locator(
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
)
await expect(card).toBeVisible()
await card.getByRole('button', { name: 'Use' }).click()
// Dialog closes and the ghost is armed; the node is not placed until the
// user clicks the canvas.
await expect(modal).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
})
})

View File

@@ -1,135 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
* must still route a not-completed user there. Drives a raw `page` so the cloud
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
* devtools backend during setup).
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
async function mockSurveyNotCompleted(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
})
)
}
// Transient auth failure: a stale workspace token makes the authenticated
// survey check 401 — the hiccup that used to bounce working users.
async function mockSurveyTransient401(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
code: 'UNAUTHORIZED',
message: 'User authentication required'
})
})
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyTransient401(page)
await bootCloud(page)
await page.goto(APP_URL)
// The full app boots — CloudSurveyView is a standalone onboarding view, so
// reaching the extension manager proves we landed on the working app and
// the transient 401 was treated as "completed", not a bounce.
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/\/cloud\/survey/)
})
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyNotCompleted(page)
await bootCloud(page)
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
})
})

View File

@@ -1,50 +1,298 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
/**
* Group nodes are a deprecated feature. Workflows that still contain group nodes
* are auto-converted to subgraphs on load (with accepted lossiness).
*/
test.describe('Group node migration', { tag: '@node' }, () => {
test('Auto-converts a loaded group node into a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_NAME = 'group_node'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
const state = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return {
groupNodeInstances: graph.nodes.filter((n) =>
String(n.type).startsWith('workflow>')
).length,
subgraphCount: graph.subgraphs.size,
hasGroupNodesExtra: !!graph.extra?.groupNodes
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await libraryTab.open()
})
expect(state.groupNodeInstances).toBe(0)
expect(state.subgraphCount).toBe(1)
expect(state.hasGroupNodesExtra).toBe(false)
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getNode(GROUP_NODE_NAME).click()
// Verify the node is added to the canvas
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
// Verify the node is added to the bookmarks tab
await expect
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([GROUP_NODE_BOOKMARK])
// Verify the bookmark node with the same name is added to the tree
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
await libraryTab
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
// Verify the node is removed from the bookmarks tab
await expect
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
await comfyPage.page
.locator('.p-tree-node-label.tree-explorer-node-label')
.first()
.hover()
await expect(
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
})
})
test(
'Loads a legacy ("/") separator group node without error and converts it',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
test('Can be added to canvas using search', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect(
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
).toBeVisible()
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
.toHaveLength(2)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const pos = await groupNode.getPosition()
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const manage = await groupNode.manageGroupNode()
await comfyPage.nextFrame()
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
await manage.close()
await expect(manage.root).toBeHidden()
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
await expect
.poll(() =>
comfyPage.page.evaluate((nodeName) => {
const {
extra: { groupNodes }
} = window.app!.graph!
const { nodes } = groupNodes![nodeName]
return nodes.reduce(
(acc, node) => acc + (node.inputs?.length ?? 0),
0
)
}, groupNodeName)
)
.toBe(4)
// Verify there are 2 visible inputs (2 have been hidden in config)
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node!.inputs.length
}, groupNodeId)
)
.toBe(2)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
})
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
)
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
.count()
return groupNodesFolderCt === 1
}
const verifyNodeLoaded = async (
comfyPage: ComfyPage,
expectedCount: number
) => {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
await groupNode.copy()
})
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 2)
})
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node across different workflows', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.clipboard.paste()
await verifyNodeLoaded(comfyPage, 1)
})
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.clipboard.paste()
const currentGraphState = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.workflow.loadGraphData(
currentGraphState as ComfyWorkflowJSON
)
await verifyNodeLoaded(comfyPage, 1)
})
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

View File

@@ -32,10 +32,6 @@ 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: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -4,6 +4,7 @@ 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
@@ -34,6 +35,56 @@ 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)
@@ -296,6 +347,55 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
)
})
test('Should handle custom node documentation paths', async ({
comfyPage
}) => {
// First load workflow with custom node
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
// Mock custom node documentation with fallback
await comfyPage.page.route(
'**/extensions/*/docs/*/en.md',
async (route) => {
await route.fulfill({ status: 404 })
}
)
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Custom Node Documentation
This is documentation for a custom node.
![Custom Image](assets/custom.png)
`
})
})
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes.map((n) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.nodeOps.getNodeRefById(nodeRefs[0])
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
if (await helpButton.isVisible()) {
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
const image = helpPage.locator('img[alt="Custom Image"]')
await expect(image).toHaveAttribute(
'src',
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
)
}
})
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
// Mock response with potentially dangerous content
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
@@ -347,33 +447,34 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
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ード
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')
@@ -382,7 +483,9 @@ 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,16 +10,13 @@ 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', () => {
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
test('Node enforces minimum size', async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
@@ -31,15 +28,17 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(size![1]).toBeGreaterThanOrEqual(550)
})
test('Does not render hidden standard widgets in Vue mode', async ({
test('Width, height, and bg_color standard widgets are hidden', async ({
comfyPage
}) => {
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()
}
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])
})
})
@@ -789,49 +788,6 @@ 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'] },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1105,56 +1105,3 @@ test.describe('Assets sidebar - drag and drop', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
})
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job1',
preview_output: {
filename: `1.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `2.png`,
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `3.png`,
type: 'input',
nodeId: '1',
mediaType: 'images'
}
})
])
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(3)
for (const [index, expectedName] of [
[0, '1.png [temp]'],
[1, '2.png [output]'],
[2, '3.png']
] as const) {
await comfyPage.nodeOps.clearGraph()
await assetsTab.assetCards.nth(index).scrollIntoViewIfNeeded()
await assetsTab.assetCards.nth(index).click({ button: 'right' })
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
await comfyPage.contextMenu.primeVueMenu.getByText('Insert as node').click()
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const fileWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileWidget.getValue()).toBe(expectedName)
}
})

View File

@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
test.describe('Model library sidebar - add node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Clicking a model defers creation until placed on the canvas', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
})
test('Ghost preview shows the model in the loader widget before placing', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
})
})
})

View File

@@ -114,25 +114,6 @@ 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

@@ -1,68 +0,0 @@
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 { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
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(createCloudAssetsFixture([]), templateApiFixture)
const test = mergeTests(comfyPageFixture, 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

@@ -29,30 +29,31 @@ The following table lists ALL core extensions in the system as of 2025-01-30:
### Main Extensions
| Extension | Description | Category |
| ----------------------- | ------------------------------------------------------------ | --------- |
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
| editAttention.ts | Implements attention editing functionality | Text |
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
| groupNode.ts | Migrates deprecated group nodes to subgraphs on load | Graph |
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
| previewAny.ts | Universal preview functionality for various data types | Preview |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
| slotDefaults.ts | Manages default values for node slots | Nodes |
| uploadAudio.ts | Handles audio file upload functionality | Audio |
| uploadImage.ts | Handles image upload functionality | Image |
| webcamCapture.ts | Provides webcam capture capabilities | Media |
| widgetInputs.ts | Implements various widget input types | Widgets |
| Extension | Description | Category |
| ----------------------- | ------------------------------------------------------------- | --------- |
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
| editAttention.ts | Implements attention editing functionality | Text |
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
| groupNodeManage.ts | Provides group node management operations | Graph |
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
| previewAny.ts | Universal preview functionality for various data types | Preview |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
| slotDefaults.ts | Manages default values for node slots | Nodes |
| uploadAudio.ts | Handles audio file upload functionality | Audio |
| uploadImage.ts | Handles image upload functionality | Image |
| webcamCapture.ts | Provides webcam capture capabilities | Media |
| widgetInputs.ts | Implements various widget input types | Widgets |
### Conditional Lines Subdirectory

View File

@@ -420,6 +420,7 @@ 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
{

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