Compare commits

...

7 Commits

Author SHA1 Message Date
Dante
c2968422e6 fix(billing): refresh workspace billing status after completed top-up (FE-932) (#12787)
## Summary

A completed workspace top-up refreshed only the balance, leaving billing
status — and `subscription.hasFunds` (derived from
`statusData.has_funds`) — stale until the next status fetch. The
completed handler now refreshes both.

## Changes

- **What**: `TopUpCreditsDialogContentWorkspace.vue` completed branch —
`await fetchBalance()` → `await Promise.all([fetchBalance(),
fetchStatus()])` (both already exposed on `useBillingContext()`).
- **Breaking**: none.

## Review Focus

- Pre-existing bug (predates the B2 facade; `main`'s top-up already
called `fetchBalance` only). Test validity proven by reverting to
balance-only → the completed case goes red on the `fetchStatus`
assertion.
- Tests: completed → both refresh; pending / failed → neither (3 cases).
typecheck / oxlint / eslint / stylelint / oxfmt / knip clean.

Fixes FE-932
2026-06-19 06:39:10 +00:00
Benjamin Lu
5acd76cb6d Add Desktop telemetry event sink (#12802)
## Summary

- initialize a Desktop-only telemetry provider in ComfyUI_frontend
- forward existing typed telemetry events through
`window.__comfyDesktop2.Telemetry.capture` using the existing event
names
- move the Desktop 2 bridge typing to the shared ambient types and let
run/execution telemetry fire when any provider is registered

## Paired change

- Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1069

## Validation

- `pnpm typecheck`
- `pnpm format:check`
- `pnpm lint`
- `pnpm knip`
- `pnpm test:unit src/platform/missingModel/missingModelDownload.test.ts
src/platform/telemetry/initDesktopTelemetry.test.ts
src/platform/telemetry/providers/desktop/DesktopTelemetryProvider.test.ts`
- YAML lint over tracked YAML files with `.yamllint`


[MAR-240](https://linear.app/comfyorg/issue/MAR-240/frontend-telemetry-pipeline-for-desktop-app-eventsink-refactor)
2026-06-18 20:19:35 -07:00
Benjamin Lu
bc885f383c Decouple run telemetry context from providers (#12925)
## Summary

Move run-button context assembly out of telemetry providers so telemetry
can initialize without importing app-mode/workspace state.

## Changes

- **What**: Providers now accept completed `RunButtonProperties`;
run-button call sites use a workspace composable to build that payload.
- **Dependencies**: None.
2026-06-18 19:26:17 -07:00
imick-io
fc4d44c3db [feat] migrate website navbar to shadcn-vue + mobile sheet drill-down (#12861)
## Summary
- Migrate the website's top nav from bespoke components (`SiteNav`,
`MobileMenu`, `NavDesktopLink`, `PillButton`, `MaskRevealButton`) to
shadcn-vue primitives (`NavigationMenu`, `Sheet`, `Button`), split into
`HeaderMain` → `HeaderMainDesktop` + `HeaderMainMobile`.
- Mobile nav becomes a `Sheet` with drill-down sub-navigation, scroll
lock, sticky CTAs, sr-only i18n title/description, and a back-to-home
logo.
- Desktop nav uses `NavigationMenu` with shared viewport, featured
cards, `NavColumn` extraction, and centralized nav data in
`data/mainNavigation.ts`.
- Adds i18n strings for nav labels, dropdown column headers, close/back
affordances, and the mobile menu description.

## Test plan
- [ ] Desktop: hover PRODUCTS / COMMUNITY / COMPANY — dropdowns open
with featured card + columns, NEW badges render, external links show the
arrow-up-right icon, viewport is shared between triggers.
- [ ] Mobile (<lg): open hamburger sheet — verify logo + close, body
scroll is locked while open, top-level nav scrolls if it overflows, CTAs
stay pinned at bottom.
- [ ] Tap COMMUNITY / PRODUCTS / COMPANY — sub-panel slides over root
nav, in-sheet BACK returns to root, links navigate correctly.
- [ ] Reload `?locale=zh-CN`: dropdown labels, sheet title/description,
BACK / close affordances all localized.
- [ ] No regressions on `/cloud`, `/cloud/pricing`, `/customers`, etc. —
pages that swap CTAs (`BrandButton` → shadcn `Button`) still render at
every breakpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-19 01:55:02 +00:00
AustinMroz
7f25d28b71 Filter canvasOnly non-preview widgets in editor (#12957)
Non preview, `canvasOnly` widgets like `control_after_generate` could be
displayed in the subgraph editor even though promoting them would have
no visual or functional effect when in vue mode.

In vue mode, these entries are now hidden from the list of candidate
items for promotion to reduce confusion.
2026-06-19 00:59:00 +00:00
Dante
67b884d0f7 fix(billing): route subscription/sign-in/credit preconditions to modal, out of error panel (FE-878) (#12785)
## Summary

Account preconditions (sign-in / subscription / credits) on running a
workflow now open their modal directly and stay out of the error panel +
error count — previously `subscription_required` fell through to a red
"1 ERROR — Subscription required to queue workflows" banner. This covers
**both** paths: the `execution_error` websocket event and the `POST
/prompt` 402 queue paywall (`{ type: "PAYMENT_REQUIRED", message:
"Subscription required to queue workflows" }`), which is the exact
payload reported in #12840.

## Changes

- **What**: `execution_error` is classified by a pure
`accountPreconditionRouting` resolver (precedence sign-in > subscription
> credits) and routed to the existing modal via
`useAccountPreconditionDialog`; `executionStore` returns early for
preconditions so they never populate `lastExecutionError` /
`lastPromptError` / `lastNodeErrors` → fully excluded from the panel and
`totalErrorCount`. Runtime credit error at a node → credits modal (out
of panel; can name the node).
- **Queue paywall**: the `queuePrompt` catch resolves the same
precondition from the `POST /prompt` 402 response and opens the modal,
short-circuiting before `lastPromptError`, so the queue paywall stays
out of the panel too. The runtime matcher learns the `"Subscription
required to queue workflows"` message.
- **Breaking**: none.

## Before / After

Free-tier queue paywall (`POST /prompt` → 402) on a cloud build:

**Before** — raw `Subscription required to queue workflows` surfaced in
the error panel (no actionable upgrade):

<img width="1600" height="873" alt="before-error-panel"
src="https://github.com/user-attachments/assets/1b76b742-16bf-47e3-9245-17e35f8f1e70"
/>

**After** — clean subscription modal opens; nothing in the error panel
or error count:

<img width="1600" height="873" alt="after-subscription-modal"
src="https://github.com/user-attachments/assets/13d238cb-20bf-4795-a530-5abcf9968dc7"
/>

## Review Focus

- **Routing-only — the run button is intentionally untouched.** The
original AC#3 ("no Subscribe-to-Run button") is superseded by the FE-978
run-lock decision (pre-emptive role-aware lock, Figma 3253-18671).
Complements #12786 (FE-978 run-lock); disjoint file sets.
- Tests: `accountPreconditionRouting` / `useAccountPreconditionDialog` /
`executionStore` — each precondition routes to its modal and is excluded
from the panel/count; precedence resolves on co-occurrence. Plus
Playwright `browser_tests/tests/subscriptionPaywallError.spec.ts` — the
queue paywall (402) stays out of the error panel, with a control
asserting ordinary queue errors still surface. typecheck / oxlint /
eslint / stylelint / oxfmt / knip clean.

Fixes FE-878
Fixes #12840
2026-06-19 00:51:52 +00:00
Benjamin Lu
05efee07ce Move Comfy Desktop bridge types into frontend (#12857)
## Summary

Adds `@comfyorg/comfyui-desktop-bridge-types` as a workspace package in
the frontend monorepo and changes the frontend app dependency to
`workspace:*`.

Adds a dedicated `Publish Desktop Bridge Types` workflow for publishing
`packages/comfyui-desktop-bridge-types` by its own package version,
without coupling it to the generated `@comfyorg/comfyui-frontend-types`
release. The generated frontend types package still emits a concrete
`@comfyorg/comfyui-desktop-bridge-types@0.1.2` dependency instead of
leaking workspace/catalog protocol references.

The Desktop2 missing-model path uses `window.__comfyDesktop2.isRemote()`
when available, but falls back to the legacy
`window.__comfyDesktop2Remote` marker so frontend rollout stays
compatible with older Desktop builds.

Paired Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1112
2026-06-18 18:02:13 -07:00
120 changed files with 3642 additions and 1435 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { getMainNavigation } from '../../../data/mainNavigation'
import { getRoutes } from '../../../config/routes.ts'
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
const mainNavigation = getMainNavigation(locale)
const isOpen = ref(false)
const activeSection = ref<string | null>(null)
const activeItem = computed(() =>
mainNavigation.find(
(item) => item.label === activeSection.value && item.columns
)
)
watch(isOpen, (open) => {
if (open) {
lockScroll()
} else {
unlockScroll()
activeSection.value = null
}
})
onUnmounted(() => {
if (isOpen.value) unlockScroll({ skipRestore: true })
})
</script>
<template>
<div>
<Sheet v-model:open="isOpen">
<SheetTrigger
:aria-label="t('nav.toggleMenu', locale)"
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
>
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
</SheetTrigger>
<SheetContent
side="right"
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
:close-label="t('nav.close', locale)"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
<SheetDescription>
{{ t('nav.mobileMenuDescription', locale) }}
</SheetDescription>
</SheetHeader>
<div>
<a
:href="routes.home"
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
>
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
<span class="sr-only">{{ t('nav.home', locale) }}</span>
</a>
</div>
<div class="relative mt-4 flex-1 overflow-hidden">
<!-- Top-level nav -->
<nav
:class="
cn(
'absolute inset-0 overflow-y-auto p-1',
activeItem ? 'opacity-0' : ''
)
"
:aria-label="t('nav.menu', locale)"
:inert="activeItem ? true : undefined"
>
<ul class="flex flex-col gap-y-8">
<li v-for="item in mainNavigation" :key="item.label">
<Button
:as="item.columns ? 'button' : 'a'"
variant="navMuted"
:type="item.columns ? 'button' : undefined"
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>
</Button>
</li>
</ul>
</nav>
<!-- Drill-down sub-panel -->
<div
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
:class="
activeItem
? 'translate-x-0'
: 'pointer-events-none translate-x-full'
"
:inert="activeItem ? undefined : true"
:aria-hidden="!activeItem"
>
<div class="size-full overflow-y-auto py-8">
<Button
type="button"
variant="link"
@click="activeSection = null"
>
<template #prepend>
<ChevronLeft />
</template>
{{ t('nav.back', locale) }}
</Button>
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
<div
v-for="column in activeItem.columns"
:key="column.header"
class="flex flex-col gap-y-3"
>
<p
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
>
{{ column.header }}
</p>
<Button
v-for="link in column.items"
:key="link.label"
:href="link.href"
variant="nav"
as="a"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
>
<NavLinkContent :item="link" :locale="locale" />
</Button>
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,21 +31,27 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (isCloud) {
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
await initTelemetry()
}
if (hasHostTelemetryBridge) {
const { initHostTelemetry } =
await import('@/platform/telemetry/initHostTelemetry')
initHostTelemetry()
}
const ComfyUIPreset = definePreset(Aura, {
semantic: {
// @ts-expect-error fixme ts strict error

View File

@@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
const { t } = useI18n()
@@ -32,6 +32,7 @@ const isMdOrLarger = breakpoints.greaterOrEqual('md')
const { permissions } = useWorkspaceUI()
const { showSubscriptionDialog } = useBillingContext()
const { trackRunButton } = useRunButtonTelemetry()
const canResubscribe = computed(() => permissions.value.canManageSubscription)
@@ -50,7 +51,7 @@ const buttonTooltip = computed(() =>
function handleSubscribeToRun() {
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccountPreconditionDialog } from './useAccountPreconditionDialog'
const mockDialogService = {
showApiNodesSignInDialog: vi.fn(),
showSubscriptionRequiredDialog: vi.fn(),
showTopUpCreditsDialog: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('routes a sign-in precondition to the API sign-in dialog with the node type', () => {
useAccountPreconditionDialog().open('sign_in', { nodeType: 'ApiNode' })
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([
'ApiNode'
])
expect(
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
})
it('routes a sign-in precondition with no node type to an empty list', () => {
useAccountPreconditionDialog().open('sign_in')
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([])
})
it('routes a subscription precondition to the subscription dialog', () => {
useAccountPreconditionDialog().open('subscription')
expect(
mockDialogService.showSubscriptionRequiredDialog
).toHaveBeenCalledTimes(1)
expect(mockDialogService.showApiNodesSignInDialog).not.toHaveBeenCalled()
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
})
it('routes a credit precondition to the top-up dialog', () => {
useAccountPreconditionDialog().open('credits', { nodeType: 'PartnerNode' })
expect(mockDialogService.showTopUpCreditsDialog).toHaveBeenCalledWith({
isInsufficientCredits: true
})
expect(
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,38 @@
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
export interface AccountPreconditionContext {
/** Node type that triggered the precondition, used as modal context. */
nodeType?: string
}
// Routes a resolved account precondition to its dedicated modal. This is the
// single seam where FE-978 attaches role-aware (member vs owner) subscription
// content: the `subscription` branch resolves to the subscription dialog, whose
// inner content FE-978 specializes for cancelled/inactive team states.
export function useAccountPreconditionDialog() {
const dialogService = useDialogService()
function open(
precondition: AccountPrecondition,
context: AccountPreconditionContext = {}
): void {
switch (precondition) {
case 'sign_in':
void dialogService.showApiNodesSignInDialog(
context.nodeType ? [context.nodeType] : []
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog()
return
case 'credits':
void dialogService.showTopUpCreditsDialog({
isInsufficientCredits: true
})
return
}
}
return { open }
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isAccountPreconditionCatalogId,
preconditionForCatalogId,
resolveAccountPrecondition
} from './accountPreconditionRouting'
import {
EXECUTION_FAILED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
describe('resolveAccountPrecondition', () => {
it('classifies a sign-in error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage: 'Unauthorized: Please login first to use this node.'
})
).toBe('sign_in')
})
it('classifies an inactive-subscription error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'InactiveSubscriptionError',
exceptionMessage:
'User has no active subscription. Please subscribe to a plan to continue.'
})
).toBe('subscription')
})
it('classifies the queue paywall (PAYMENT_REQUIRED) as a subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Subscription required to queue workflows'
})
).toBe('subscription')
})
it('classifies a subscription-upgrade error as a subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage:
'the following private models require a subscription upgrade: flux-pro'
})
).toBe('subscription')
})
it('classifies an account credit error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'InsufficientFundsError',
exceptionMessage:
'Payment Required: Please add credits to your account to use this node.'
})
).toBe('credits')
})
it('classifies a workspace credit error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage:
'Payment Required: Please add credits to your workspace to continue.'
})
).toBe('credits')
})
it('returns undefined for an ordinary workflow error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA out of memory'
})
).toBeUndefined()
})
})
describe('preconditionForCatalogId / isAccountPreconditionCatalogId', () => {
it('maps every precondition catalog id', () => {
expect(preconditionForCatalogId(SIGN_IN_REQUIRED_CATALOG_ID)).toBe(
'sign_in'
)
expect(preconditionForCatalogId(SUBSCRIPTION_REQUIRED_CATALOG_ID)).toBe(
'subscription'
)
expect(
preconditionForCatalogId(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID)
).toBe('subscription')
expect(preconditionForCatalogId(INSUFFICIENT_CREDITS_CATALOG_ID)).toBe(
'credits'
)
expect(
preconditionForCatalogId(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
).toBe('credits')
})
it('does not treat a workflow error catalog id as a precondition', () => {
expect(
preconditionForCatalogId(EXECUTION_FAILED_CATALOG_ID)
).toBeUndefined()
expect(isAccountPreconditionCatalogId(EXECUTION_FAILED_CATALOG_ID)).toBe(
false
)
expect(isAccountPreconditionCatalogId(undefined)).toBe(false)
})
})

View File

@@ -0,0 +1,44 @@
import {
INSUFFICIENT_CREDITS_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import type { RuntimeErrorInfo } from './runtimeErrorMatcher'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
// Account preconditions are gating states (sign-in, subscription, credits) that
// must open their own modal instead of surfacing as a workflow error. They are
// excluded from the error panel and the error count.
export type AccountPrecondition = 'sign_in' | 'subscription' | 'credits'
const CATALOG_ID_TO_PRECONDITION = new Map<string, AccountPrecondition>([
[SIGN_IN_REQUIRED_CATALOG_ID, 'sign_in'],
[SUBSCRIPTION_REQUIRED_CATALOG_ID, 'subscription'],
[SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, 'subscription'],
[INSUFFICIENT_CREDITS_CATALOG_ID, 'credits'],
[WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID, 'credits']
])
export function preconditionForCatalogId(
catalogId: string | undefined
): AccountPrecondition | undefined {
if (!catalogId) return undefined
return CATALOG_ID_TO_PRECONDITION.get(catalogId)
}
export function isAccountPreconditionCatalogId(
catalogId: string | undefined
): boolean {
return preconditionForCatalogId(catalogId) !== undefined
}
// Classifies a single runtime error payload into the account precondition it
// represents, or `undefined` when it is an ordinary workflow error.
export function resolveAccountPrecondition(
info: RuntimeErrorInfo
): AccountPrecondition | undefined {
const match = resolveRuntimeCatalogMatch(info)
return preconditionForCatalogId(match?.catalogId)
}

View File

@@ -37,7 +37,8 @@ const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
'User has no active subscription. Please subscribe to a plan to continue.'
'User has no active subscription. Please subscribe to a plan to continue.',
'Subscription required to queue workflows'
])
const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX =
'the following private models require a subscription upgrade:'
@@ -120,7 +121,7 @@ const PREPROCESSING_FAILED_PREFIXES = [
'Failed to complete preparation:'
]
interface RuntimeErrorInfo {
export interface RuntimeErrorInfo {
exceptionType: string
exceptionMessage: string
}

View File

@@ -39,7 +39,6 @@ beforeEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
delete window.__comfyDesktop2
delete window.__comfyDesktop2Remote
})
describe('fetchModelMetadata', () => {
@@ -258,7 +257,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -289,7 +291,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockRejectedValue(bridgeError)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -323,7 +328,10 @@ describe('downloadModel', () => {
.mockImplementation(() => {
throw bridgeError
})
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -353,8 +361,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2Remote = true
window.__comfyDesktop2 = {
isRemote: () => true,
downloadModel: desktopDownloadModel
}
downloadModel(
{

View File

@@ -2,21 +2,7 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
import type { ComfyDesktop2Bridge } from '@/types'
const ALLOWED_SOURCES = [
'https://civitai.com/',
@@ -55,7 +41,7 @@ async function startDesktop2ModelDownload(
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel(model.url, model.name, model.directory)
await bridge.downloadModel?.(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
@@ -90,7 +76,7 @@ export function downloadModel(
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
if (desktop2Bridge?.downloadModel && !desktop2Bridge.isRemote()) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}

View File

@@ -1,5 +1,3 @@
import { api } from '@/scripts/api'
import {
cachedTeamWorkspacesEnabled,
remoteConfig,
@@ -14,6 +12,13 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
const { api } = await import('@/scripts/api')
return api.fetchApi('/features', { cache: 'no-store' })
}
/**
* Loads remote configuration from the backend /features endpoint
* and updates the reactive remoteConfig ref.
@@ -29,9 +34,7 @@ export async function refreshRemoteConfig(
const { useAuth = true } = options
try {
const response = useAuth
? await api.fetchApi('/features', { cache: 'no-store' })
: await fetch('/api/features', { cache: 'no-store' })
const response = await fetchRemoteConfig(useAuth)
if (response.ok) {
const config = await response.json()

View File

@@ -94,6 +94,7 @@ export type RemoteConfig = {
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
enable_telemetry?: boolean
model_upload_button_enabled?: boolean
asset_rename_enabled?: boolean
private_models_enabled?: boolean

View File

@@ -9,7 +9,6 @@ import type {
ShareLinkOpenedMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -19,6 +18,7 @@ import type {
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
@@ -112,11 +112,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
this.dispatch((provider) => provider.trackRunButton?.(options))
trackRunButton(properties: RunButtonProperties): void {
this.dispatch((provider) => provider.trackRunButton?.(properties))
}
startTopupTracking(): void {

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { setTelemetryRegistry, useTelemetry } from '@/platform/telemetry'
import { initHostTelemetry } from '@/platform/telemetry/initHostTelemetry'
import { TelemetryEvents } from '@/platform/telemetry/types'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
describe('initHostTelemetry', () => {
afterEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
setTelemetryRegistry(null)
localStorage.clear()
delete window.__comfyDesktop2
})
it('leaves the registry untouched when enable_telemetry is on but the host Telemetry bridge is absent', () => {
remoteConfig.value = { enable_telemetry: true }
initHostTelemetry()
expect(useTelemetry()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('leaves the registry untouched when enable_telemetry is off', () => {
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: { capture: vi.fn() }
}
remoteConfig.value = { enable_telemetry: false }
initHostTelemetry()
expect(useTelemetry()).toBeNull()
})
it('registers the host telemetry sink when enable_telemetry and the bridge are present', () => {
const capture = vi.fn()
window.__comfyDesktop2 = { isRemote: () => false, Telemetry: { capture } }
remoteConfig.value = { enable_telemetry: true }
initHostTelemetry()
useTelemetry()?.trackSignupOpened()
expect(capture).toHaveBeenCalledWith(
TelemetryEvents.USER_SIGN_UP_OPENED,
undefined
)
})
})

View File

@@ -0,0 +1,23 @@
import { setTelemetryRegistry } from './index'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import { TelemetryRegistry } from './TelemetryRegistry'
import { HostTelemetrySink } from './providers/host/HostTelemetrySink'
const ENABLE_TELEMETRY_FEATURE = 'enable_telemetry'
function isHostTelemetryEnabled(): boolean {
const override = getDevOverride<boolean>(ENABLE_TELEMETRY_FEATURE)
if (override !== undefined) return override
return remoteConfig.value.enable_telemetry === true
}
export function initHostTelemetry(): void {
if (!isHostTelemetryEnabled()) return
if (!window.__comfyDesktop2?.Telemetry) return
const registry = new TelemetryRegistry()
registry.registerProvider(new HostTelemetrySink())
setTelemetryRegistry(registry)
}

View File

@@ -1,11 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'app' },
isAppMode: { value: true }
})
}))
import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -192,8 +185,22 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({ trigger_source: 'button' })
provider.trackRunButton({
subscribe_to_run: false,
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',

View File

@@ -5,7 +5,6 @@ import type {
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -13,6 +12,7 @@ import type {
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
@@ -29,8 +29,7 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { useAppMode } from '@/composables/useAppMode'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { TelemetryEvents } from '../../types'
/**
* Google Tag Manager telemetry provider.
@@ -154,7 +153,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.pushEvent('begin_checkout', metadata)
this.pushEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackSubscription(
@@ -183,18 +182,13 @@ export class GtmTelemetryProvider implements TelemetryProvider {
)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const { mode, isAppMode } = useAppMode()
trackRunButton(properties: RunButtonProperties): void {
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown',
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
subscribe_to_run: properties.subscribe_to_run,
trigger_source: properties.trigger_source ?? 'unknown',
view_mode: properties.view_mode,
is_app_mode: properties.is_app_mode,
dock_state: properties.dock_state
})
}

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