Compare commits

..

6 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
ef0255e8e0 feat: time-bound /features bootstrap fetch so app.mount can never hang
The frontend awaits refreshRemoteConfig({ useAuth: false }) before mount
so services see the server-configured comfy_api_base_url on first call.
A wedged /features endpoint would otherwise block mount indefinitely.
Cap with a 5s AbortController; on timeout the catch block already clears
remoteConfig and consumers fall back to build-time defaults — same
fallback behavior as a 4xx/5xx response.
2026-05-31 13:51:55 -04:00
Hunter Senft-Grupp
abb3b8921d test: pin backward compat with older comfyui that omits the URL keys 2026-05-31 13:44:50 -04:00
Hunter Senft-Grupp
29d054ee93 refactor: freeze registry client base URL on first use
The base URL is expected to be stable for the lifetime of the
application once read from /api/features. A request-time interceptor
implies it could change between requests, which it cannot. Replace it
with a lazy + memoized client factory (es-toolkit `once`) that reads
the URL exactly once — after main.ts has populated remoteConfig — and
then freezes it.
2026-05-31 13:41:35 -04:00
Hunter Senft-Grupp
2ce68c8a58 refactor: use axios interceptor instead of watcher for runtime base URL
Reading the base URL at axios.create() time captures whatever
remoteConfig holds at module-load — which on OSS is the empty default,
because static imports are hoisted above main.ts's top-level
'await refreshRemoteConfig()'. A subsequent 'watch' never fires because
the value is already correct by the time the watcher is established.
A request-time interceptor reads the current URL on every call, which
is both simpler and actually correct.
2026-05-31 13:38:20 -04:00
Hunter Senft-Grupp
112b3526a9 test: pin fallback to build-time default when override is absent or empty 2026-05-31 13:36:02 -04:00
Hunter Senft-Grupp
0c31464cc5 feat: honor server-provided comfy api/platform base URLs in all distributions
Previously only the cloud distribution consulted window.__CONFIG__ for
comfy_api_base_url / comfy_platform_base_url; OSS builds were locked to
the build-time URL. This made it impossible to point a local comfyui at
an ephemeral api.comfy.org without rebuilding the frontend.

- main.ts: refreshRemoteConfig({ useAuth: false }) now runs in every
  distribution against the local /api/features endpoint
- config/comfyApi.ts: drop the isCloud short-circuit in
  getComfyApiBaseUrl / getComfyPlatformBaseUrl
- services/comfyRegistryService.ts: route through getComfyApiBaseUrl
  instead of hardcoding api.comfy.org; mirrors customerEventsService's
  reactive watch pattern so the registry/manager calls also follow
  runtime overrides

Requires the companion comfyui change that adds comfy_api_base_url and
comfy_platform_base_url to /features.
2026-05-31 13:31:04 -04:00
576 changed files with 6373 additions and 35969 deletions

View File

@@ -5,8 +5,6 @@ on:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
types:
- completed
branches-ignore:
- main
permissions:
contents: read

View File

@@ -84,8 +84,6 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/hoisted-apis-on-top": "error",
"typescript/no-misused-spread": "error",
"vitest/consistent-each-for": [
"error",
{

View File

@@ -1,60 +1,95 @@
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
/src/components/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/imagecrop/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useImageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
# Painter
/src/extensions/core/painter.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
# 3D
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3dLazy.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/services/load3dService.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
# Manager
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -1,140 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates/terms'
const SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
test.describe('Affiliate Terms — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('exposes one anchor per legal section in order', async ({ page }) => {
for (const id of SECTION_IDS) {
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
}
const orderedIds = await page.evaluate(
(ids) => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
return elements
.slice()
.sort((a, b) => {
const relation = a.compareDocumentPosition(b)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
.map((el) => el.id)
},
[...SECTION_IDS]
)
expect(orderedIds).toEqual([...SECTION_IDS])
})
test('renders an effective date footer', async ({ page }) => {
await expect(page.getByText(/Effective Date:/)).toBeVisible()
})
test('skips internal-only sections (competitive analysis, open questions)', async ({
page
}) => {
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
await expect(
page.getByText(/Open questions for legal review/i)
).toHaveCount(0)
})
})
test.describe('Affiliate Terms — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('clicking a desktop TOC link scrolls to the matching section', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await expect(desktopToc).toBeVisible()
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
await link.click()
const target = page.locator('[id="5-prohibited-activities"]')
await expect(target).toBeInViewport()
})
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe('#7-termination')
})
})
test.describe('Affiliate Terms — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows a collapsed accordion TOC by default', async ({ page }) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await expect(accordion).toBeVisible()
await expect(accordion).not.toHaveAttribute('open', '')
})
test('expanding the accordion reveals every section link', async ({
page
}) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await accordion.locator('summary').click()
await expect(accordion).toHaveAttribute('open', '')
for (const id of SECTION_IDS) {
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
}
})
test('headings remain readable at narrow viewports without horizontal overflow', async ({
page
}) => {
const heading = page.getByRole('heading', { name: '1. Program Overview' })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'heading box').not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
})
})

View File

@@ -1,148 +0,0 @@
import { expect } from '@playwright/test'
import { affiliateFaqs } from '../src/data/affiliateFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates'
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
const TERMS_PATH = '/affiliates/terms'
const FAQ_COUNT = affiliateFaqs.length
const FIRST_FAQ = affiliateFaqs[0]
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('renders the closing CTA heading and apply button', async ({ page }) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const ctaHeading = ctaSection.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await expect(applyButton).toBeVisible()
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
await expect(applyButton).toHaveAttribute('target', '_blank')
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
})
test('CTA section links to the affiliate terms page in the same tab', async ({
page
}) => {
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
await termsLink.scrollIntoViewIfNeeded()
await expect(termsLink).toBeVisible()
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
await expect(termsLink).not.toHaveAttribute('target', '_blank')
})
})
test.describe('Affiliates landing — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('emits FAQPage structured data with one entry per FAQ', async ({
page
}) => {
const faqJsonLd = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
const match = scripts.find((s) =>
(s.textContent ?? '').includes('FAQPage')
)
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({
page,
context
}) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await applyButton.scrollIntoViewIfNeeded()
const popupPromise = context.waitForEvent('page')
await applyButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
expect(
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
popupUrl.includes('docs.google.com/forms')
).toBe(true)
await popup.close()
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Affiliates landing — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading at narrow viewports', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
})
test('closing CTA stays within the viewport width', async ({ page }) => {
const ctaHeading = page.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const box = await ctaHeading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -4,10 +4,6 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -42,7 +38,7 @@ test.describe('Download page @smoke', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
@@ -56,61 +52,6 @@ test.describe('Download page @smoke', () => {
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
@@ -235,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()

View File

@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')

View File

@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

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

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -1,3 +0,0 @@
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,4 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#211927"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,4 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -31,4 +31,28 @@ Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
@@ -86,20 +86,6 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
// Stub entries that exist only to issue 301 redirects from old slugs to
// their new canonical slugs. Keeps renames reproducible across regenerations.
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
{
slug: 'grok-image',
canonicalSlug: 'grok-imagine',
name: 'Grok Image',
displayName: 'Grok Image',
directory: 'partner_nodes',
huggingFaceUrl: '',
workflowCount: 0
}
]
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
@@ -313,8 +299,7 @@ function run(): void {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
}`
)
}
}
@@ -382,7 +367,7 @@ function run(): void {
displayName: m.name
}))
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(

View File

@@ -1,60 +0,0 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
import GlassCard from '../common/GlassCard.vue'
type Benefit = { id: string; description: string }
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
defineProps<{
heading: string
benefits: readonly Benefit[]
primaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<GlassCard class="mx-auto max-w-7xl">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
<article
v-for="(benefit, index) in benefits"
:key="benefit.id"
class="flex flex-col gap-6 rounded-4xl bg-primary-comfy-ink p-6 lg:p-8"
>
<span
class="text-primary-comfy-yellow font-mono text-sm font-bold tracking-wide"
>
{{ String(index + 1).padStart(2, '0') }}
</span>
<p
class="text-base/relaxed font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ benefit.description }}
</p>
</article>
</div>
</GlassCard>
<div v-if="primaryCta" class="mt-10 flex justify-center lg:mt-12">
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
size="lg"
class="px-20 py-4 text-base uppercase"
variant="outline"
>
{{ primaryCta.label }}
</BrandButton>
</div>
</section>
</template>

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
type Asset = {
id: string
title: string
download: string
preview: string
}
defineProps<{
heading: string
subheading: string
downloadLabel: string
assets: readonly Asset[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="mx-auto max-w-6xl text-center">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<p class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/70">
{{ subheading }}
</p>
</div>
<ul
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
>
<li
v-for="asset in assets"
:key="asset.id"
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
>
<div
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
>
<img
:src="asset.preview"
:alt="asset.title"
class="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div class="flex flex-1 flex-col gap-2 p-5">
<h3 class="text-base font-light text-primary-comfy-canvas">
{{ asset.title }}
</h3>
<a
:href="asset.download"
:download="asset.download.split('/').pop()"
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
>
{{ downloadLabel }}
<span aria-hidden="true"></span>
</a>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import GlassCard from '../common/GlassCard.vue'
import CheckIcon from '../icons/CheckIcon.vue'
type Criterion = { id: string; label: string }
defineProps<{
heading: string
subheading: string
eyebrow?: string
criteria: readonly Criterion[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<GlassCard class="px-6 py-10 lg:px-16 lg:py-14">
<div
class="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-4xl">
{{ subheading }}
</h3>
<div class="flex flex-col gap-6">
<span
v-if="eyebrow"
class="text-xs font-bold tracking-widest text-primary-comfy-canvas uppercase"
>
{{ eyebrow }}
</span>
<ul class="flex flex-col gap-4">
<li
v-for="criterion in criteria"
:key="criterion.id"
class="flex items-start gap-3"
>
<CheckIcon
class="text-primary-comfy-yellow mt-0.5 size-5 shrink-0"
/>
<span class="text-sm text-primary-comfy-canvas lg:text-base">
{{ criterion.label }}
</span>
</li>
</ul>
</div>
</div>
</GlassCard>
</section>
</template>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
type TermsLink = {
label: string
href: string
}
defineProps<{
heading: string
primaryCta: Cta
termsLink: TermsLink
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
variant="outline"
size="lg"
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
>
{{ primaryCta.label }}
</BrandButton>
<a
:href="termsLink.href"
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
>
{{ termsLink.label }}
</a>
</section>
</template>

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
const { faqs } = defineProps<{
heading: string
faqs: readonly Faq[]
}>()
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
() => faqs.length,
(length) => {
if (length === expanded.length) return
expanded.length = 0
for (let i = 0; i < length; i += 1) expanded.push(false)
}
)
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ heading }}
</h2>
</div>
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${faq.id}`"
type="button"
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${faq.id}`"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between text-left',
index === 0 ? 'pb-6' : 'py-6'
)
"
@click="toggle(index)"
>
<span
:class="
cn(
'text-lg font-light md:text-xl',
expanded[index]
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas'
)
"
>
{{ faq.question }}
</span>
<span
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
aria-hidden="true"
>
{{ expanded[index] ? '' : '+' }}
</span>
</button>
<section
v-show="expanded[index]"
:id="`faq-panel-${faq.id}`"
role="region"
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,153 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import CheckIcon from '../icons/CheckIcon.vue'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
label: string
}
const {
locale = 'en',
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
titleHighlight,
features = [],
primaryCta,
secondaryCta,
imageSrc,
imageAlt = '',
imageWidth = 800,
imageHeight = 600,
imagePosition = 'right',
videoSrc,
videoPoster,
videoTracks = [],
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
} = defineProps<{
locale?: Locale
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
imageSrc?: string
imageAlt?: string
imageWidth?: number
imageHeight?: number
imagePosition?: 'left' | 'right'
videoSrc?: string
videoPoster?: string
videoTracks?: VideoTrack[]
videoAutoplay?: boolean
videoLoop?: boolean
videoMinimal?: boolean
videoHideControls?: boolean
}>()
</script>
<template>
<section
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
)
"
>
<div class="w-full lg:flex-1">
<ProductHeroBadge
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
{{ title }}
</template>
<template v-else>{{ title }}</template>
</h1>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
:key="feature"
class="flex items-start gap-3 text-base text-primary-comfy-canvas"
>
<CheckIcon class="text-primary-comfy-yellow mt-1 size-5 shrink-0" />
{{ feature }}
</li>
</ul>
<div class="mt-10 flex flex-col gap-4 sm:flex-row">
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
size="lg"
class="px-8 py-4 text-base uppercase"
>
{{ primaryCta.label }}
</BrandButton>
<BrandButton
v-if="secondaryCta"
:href="secondaryCta.href"
:target="secondaryCta.target"
variant="outline"
size="lg"
class="px-8 py-4 text-base uppercase"
>
{{ secondaryCta.label }}
</BrandButton>
</div>
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</div>
</section>
</template>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Step = { id: string; label: string; description: string }
defineProps<{
heading: string
steps: readonly Step[]
}>()
const isRtlRow = (i: number) => Math.floor(i / 2) % 2 === 1
const isFullSpan = (i: number, total: number) =>
i === total - 1 && total % 2 === 1
function hasHorizontalConnector(i: number, total: number) {
if (isFullSpan(i, total)) return false
if (!isRtlRow(i) && i % 2 === 0 && i + 1 < total) return true
if (isRtlRow(i) && i % 2 === 1) return true
return false
}
function hasMobileVertical(i: number, total: number) {
return i < total - 1
}
function hasLgVertical(i: number, total: number) {
return i % 2 === 1 && i + 1 < total
}
function cardClass(i: number, total: number) {
const fullSpan = isFullSpan(i, total)
const rtl = isRtlRow(i)
return cn(
'border-primary-comfy-yellow relative rounded-3xl border-2 p-8 lg:p-10',
fullSpan && 'lg:col-span-2',
!fullSpan && rtl && i % 2 === 0 && 'lg:col-start-2',
!fullSpan && rtl && i % 2 === 1 && 'lg:col-start-1'
)
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<div
class="mx-auto grid max-w-3xl grid-cols-1 gap-4 lg:grid-flow-dense lg:grid-cols-2"
>
<div
v-for="(step, index) in steps"
:key="step.id"
:class="cardClass(index, steps.length)"
>
<span
class="bg-primary-comfy-yellow font-formula-narrow inline-block -skew-x-12 rounded-sm px-3 py-1.5 text-sm font-bold tracking-wide text-primary-comfy-ink uppercase lg:text-base"
>
<span class="inline-block skew-x-12">
{{ index + 1 }}. {{ step.label }}
</span>
</span>
<p class="mt-6 text-sm/relaxed text-primary-comfy-canvas lg:text-base">
{{ step.description }}
</p>
<NodeUnionIcon
v-if="hasHorizontalConnector(index, steps.length)"
class="text-primary-comfy-yellow absolute top-1/2 right-0 hidden size-4 translate-x-[calc(100%+2px)] -translate-y-1/2 scale-x-150 rotate-90 lg:block"
/>
<NodeUnionIcon
v-if="
hasMobileVertical(index, steps.length) ||
hasLgVertical(index, steps.length)
"
:class="
cn(
'text-primary-comfy-yellow absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-[calc(100%+2px)] scale-x-150',
!hasMobileVertical(index, steps.length) && 'hidden lg:block',
!hasLgVertical(index, steps.length) && 'lg:hidden'
)
"
/>
</div>
</div>
</section>
</template>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import WireNodeLayout from '../common/WireNodeLayout.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons: TranslationKey[] = [
const reasons = [
'careers.whyJoin.reason1',
'careers.whyJoin.reason2',
'careers.whyJoin.reason3',
'careers.whyJoin.reason4',
'careers.whyJoin.reason5'
]
] as const
</script>
<template>

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

@@ -1,39 +1,32 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { BrandButtonVariants } from './brandButton.variants'
import { brandButtonVariants } from './brandButton.variants'
const props = defineProps<{
const {
href,
target,
variant,
size,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
variant?: BrandButtonVariants['variant']
size?: BrandButtonVariants['size']
class?: HTMLAttributes['class']
}>()
const resolvedRel = computed(
() =>
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
)
</script>
<template>
<component
:is="props.href ? 'a' : 'button'"
:href="props.href"
:target="props.target"
:rel="resolvedRel"
:class="
cn(
brandButtonVariants({ variant: props.variant, size: props.size }),
props.class ?? ''
)
"
:is="href ? 'a' : 'button'"
:href
:target
:class="cn(brandButtonVariants({ variant, size }), customClass)"
>
<span class="ppformula-text-center">
<slot />

View File

@@ -1,53 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
primaryLabelKey,
primaryHref,
secondaryLabelKey,
secondaryHref
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
primaryLabelKey: TranslationKey
primaryHref?: string
secondaryLabelKey?: TranslationKey
secondaryHref?: string
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<BrandButton
:href="primaryHref"
variant="solid"
size="xs"
class="uppercase"
>
{{ t(primaryLabelKey, locale) }}
</BrandButton>
<BrandButton
v-if="secondaryLabelKey"
:href="secondaryHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(secondaryLabelKey, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import type { EventItem } from '../../content.config'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
descriptionKey,
notifyLabelKey,
notifyHref,
events
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
descriptionKey: TranslationKey
notifyLabelKey: TranslationKey
notifyHref?: string
events: readonly EventItem[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-12">
<div
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
>
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</a>
</div>
</div>
</div>
</section>
</template>

View File

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

View File

@@ -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,165 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PillButton from './PillButton.vue'
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const AsButton: Story = {
args: { type: 'button' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Submit</PillButton>'
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Read More</PillButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `
<PillButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</PillButton>
`
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Go Back</PillButton>'
})
}
export const RevealLabelOnHover: Story = {
args: { href: '#', hideLabel: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Unavailable</PillButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { PillButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
</div>
</div>
</div>
`
})
}

View File

@@ -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

@@ -2,7 +2,7 @@
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'DESKTOP'
text = 'LOCAL'
} = defineProps<{
logoSrc?: string
logoAlt?: string
@@ -20,7 +20,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
@@ -37,7 +37,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"

View File

@@ -43,7 +43,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('footer.blog', locale),
href: externalLinks.blog,
@@ -68,10 +67,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
},
{
label: t('footer.affiliateProgram', locale),
href: routes.affiliates
}
]
}

View File

@@ -52,7 +52,6 @@ const navLinks: NavLink[] = [
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
@@ -94,7 +93,7 @@ const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'DESKTOP',
core: 'LOCAL',
href: routes.download,
primary: false
},
@@ -164,7 +163,7 @@ onMounted(() => {
/>
<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"
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -28,18 +28,14 @@ const {
poster,
tracks = [],
autoplay = false,
loop = false,
minimal = false,
hideControls = false
minimal = false
} = defineProps<{
locale?: Locale
src?: string
poster?: string
tracks?: VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -204,9 +200,8 @@ function toggleFullscreen() {
crossorigin="anonymous"
playsinline
:autoplay
:loop
muted
@click="hideControls ? undefined : (playing = !playing)"
@click="playing = !playing"
>
<track
v-for="track in tracks"
@@ -220,7 +215,7 @@ function toggleFullscreen() {
<!-- Minimal centered play/pause button -->
<div
v-if="minimal && src && !hideControls"
v-if="minimal && src"
:class="
cn(
'absolute inset-0 flex items-center justify-center transition-opacity duration-300',
@@ -240,7 +235,7 @@ function toggleFullscreen() {
<!-- Bottom control bar -->
<div
v-if="src && !minimal && !hideControls"
v-if="src && !minimal"
:class="
cn(
'absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 transition-opacity duration-300 lg:px-6 lg:py-5',
@@ -290,7 +285,7 @@ function toggleFullscreen() {
@click="toggleFullscreen"
>
<svg
class="size-3.5 text-primary-comfy-ink lg:size-4"
class="text-primary-comfy-ink size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -336,7 +331,7 @@ function toggleFullscreen() {
<!-- Muted icon -->
<svg
v-if="muted"
class="size-3.5 text-primary-comfy-ink lg:size-4"
class="text-primary-comfy-ink size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
@@ -354,7 +349,7 @@ function toggleFullscreen() {
<!-- Unmuted icon -->
<svg
v-else
class="size-3.5 text-primary-comfy-ink lg:size-4"
class="text-primary-comfy-ink size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"

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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
@@ -7,16 +7,12 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
mobile = false
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -35,15 +31,13 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<!-- Desktop hover overlay -->
<div
@@ -53,7 +47,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +76,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -12,7 +12,7 @@ import {
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
@@ -123,11 +123,11 @@ onUnmounted(() => {
<!-- Close button -->
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
@@ -136,7 +136,7 @@ onUnmounted(() => {
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'

View File

@@ -2,15 +2,13 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<path
d="M5 11.5811L10.2582 18.0581L20 6.05811"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useId } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
const clipId = `node-union-icon-clip-${useId()}`
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<g :clip-path="`url(#${clipId})`">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M-1.59144e-05 0H100V100H-1.59144e-05V0ZM32.3741 50C32.3741 77.0727 16.2692 99.0196 -3.59714 99.0196C-23.4635 99.0196 -39.5684 77.0727 -39.5684 50C-39.5684 22.9273 -23.4635 0.980392 -3.59714 0.980392C16.2692 0.980392 32.3741 22.9273 32.3741 50ZM139.568 50C139.568 77.0727 123.463 99.0196 103.597 99.0196C83.7309 99.0196 67.6259 77.0727 67.6259 50C67.6259 22.9273 83.7309 0.980392 103.597 0.980392C123.463 0.980392 139.568 22.9273 139.568 50Z"
/>
</g>
<defs>
<clipPath :id="clipId">
<rect width="100" height="100" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const tags = ['Seadance 2.0', 'Image To Video']
const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div>
<h2
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('learning.featured.author', locale) }}
</p>
</div>
<p
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton
variant="outline"
size="xs"
class="uppercase"
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
>
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<ul class="mt-2 flex flex-wrap gap-3">
<li v-for="tag in tags" :key="tag">
<Badge variant="subtle">{{ tag }}</Badge>
</li>
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
minimal
/>
</div>
</div>
</section>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
<br />
{{ t('learning.heroTitle.line2', locale) }}
</h1>
</section>
</template>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: ResolvedTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
})
onUnmounted(() => {
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<VideoPlayer
:key="tutorial.slug"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
autoplay
class="w-full"
/>
</div>
<h2
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -1,121 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { getTutorialPosterSrc } from '../../utils/tutorial'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const activeTutorialSlug = ref<string | null>(null)
const activeTutorial = () =>
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in tutorials"
:key="tutorial.slug"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="sm"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ tag }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialSlug = null"
/>
</section>
</template>

View File

@@ -1,244 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { scrollTo } from '../../scripts/smoothScroll'
const {
prefix,
locale = 'en',
tocLabelKey
} = defineProps<{
prefix: string
locale?: Locale
tocLabelKey: TranslationKey
}>()
interface Block {
type: 'paragraph' | 'list'
key: TranslationKey
}
interface LegalSection {
id: string
title: string
blocks: Block[]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildSections(): LegalSection[] {
const labelRegex = new RegExp(`^${escapeRegex(prefix)}\\.([^.]+)\\.label$`)
const sectionIds: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !sectionIds.includes(match[1])) sectionIds.push(match[1])
}
return sectionIds.map((id) => {
const blockRegex = new RegExp(
`^${escapeRegex(prefix)}\\.${escapeRegex(id)}\\.block\\.(\\d+)$`
)
const indices: number[] = []
for (const key of translationKeys) {
const match = key.match(blockRegex)
if (match) indices.push(parseInt(match[1]))
}
indices.sort((a, b) => a - b)
const blocks: Block[] = indices.map((i) => {
const key = `${prefix}.${id}.block.${i}` as TranslationKey
const value = t(key, locale)
return { type: value.includes('\n') ? 'list' : 'paragraph', key }
})
const titleKey = `${prefix}.${id}.title` as TranslationKey
return {
id,
title: hasKey(titleKey)
? t(titleKey, locale)
: t(`${prefix}.${id}.label` as TranslationKey, locale),
blocks
}
})
}
const sections = buildSections()
const tocItems = computed(() =>
sections.map((s) => ({ id: s.id, title: s.title }))
)
const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
const mobileTocOpen = ref(false)
let isScrolling = false
const HEADER_OFFSET = -144
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
best = entry
}
if (best) activeSection.value = best.target.id
},
{ rootMargin: '-20% 0px -60% 0px' }
)
function scrollToSection(id: string) {
activeSection.value = id
isScrolling = true
mobileTocOpen.value = false
const nextHash = `#${id}`
if (window.location.hash !== nextHash) {
history.replaceState(null, '', nextHash)
}
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
})
return
}
isScrolling = false
}
function listItems(key: TranslationKey): string[] {
return t(key, locale).split('\n')
}
</script>
<template>
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-12 lg:pb-32">
<div class="mx-auto max-w-7xl lg:flex lg:gap-16">
<aside class="lg:w-64 lg:shrink-0">
<details
:open="mobileTocOpen"
class="border-transparency-white-t4 mb-8 rounded-2xl border bg-(--site-bg-soft) lg:hidden"
@toggle="
(e) => (mobileTocOpen = (e.target as HTMLDetailsElement).open)
"
>
<summary
class="text-primary-comfy-canvas flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-semibold tracking-wide select-none"
>
<span>{{ t(tocLabelKey, locale) }}</span>
<span
:class="
mobileTocOpen
? 'rotate-180 transition-transform'
: 'transition-transform'
"
aria-hidden="true"
>
</span>
</summary>
<ul class="border-transparency-white-t4 border-t p-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
:class="
cn(
'hover:bg-transparency-white-t4 block rounded-lg px-3 py-2 text-sm transition-colors',
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
)
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</details>
<nav
class="hidden lg:sticky lg:top-32 lg:block"
:aria-label="t(tocLabelKey, locale)"
>
<p
class="text-primary-warm-gray mb-4 text-xs font-semibold tracking-widest uppercase"
>
{{ t(tocLabelKey, locale) }}
</p>
<ul class="space-y-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
class="hover:text-primary-comfy-canvas block text-sm/snug transition-colors"
:class="
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</nav>
</aside>
<article class="flex-1 lg:max-w-3xl">
<section
v-for="section in sections"
:id="section.id"
:ref="sectionRefs.set"
:key="section.id"
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
>
<h2
class="text-primary-comfy-canvas mb-6 text-2xl font-light lg:text-3xl"
>
{{ section.title }}
</h2>
<template v-for="block in section.blocks" :key="block.key">
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed lg:text-base/relaxed"
v-html="t(block.key, locale)"
/>
<ul
v-else
class="mt-4 space-y-2 pl-5 text-sm/relaxed lg:text-base/relaxed"
>
<li
v-for="(item, j) in listItems(block.key)"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<span
class="bg-primary-comfy-yellow mt-2 size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span v-html="item" />
</li>
</ul>
</template>
</section>
</article>
</div>
</section>
</template>

View File

@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, t, translationKeys } from '../../i18n/translations'
const PREFIX = 'affiliate-terms'
const EXPECTED_SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
function deriveAffiliateSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('affiliate terms i18n', () => {
it('exposes the eleven canonical sections in numeric order', () => {
const ids = deriveAffiliateSectionIds()
expect(ids).toEqual([...EXPECTED_SECTION_IDS])
})
it('every section has a label, title, and at least one block', () => {
for (const id of EXPECTED_SECTION_IDS) {
expect(hasKey(`${PREFIX}.${id}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('section titles follow the "N. Section Name" pattern', () => {
for (const id of EXPECTED_SECTION_IDS) {
const title = t(`${PREFIX}.${id}.title` as never)
const numberPrefix = id.split('-')[0]
expect(title).toMatch(new RegExp(`^${numberPrefix}\\. `))
}
})
it('exposes the effective date and page-chrome keys editors will need', () => {
expect(hasKey('affiliate-terms.effective-date')).toBe(true)
expect(hasKey('affiliate-terms.page.title')).toBe(true)
expect(hasKey('affiliate-terms.page.heading')).toBe(true)
expect(hasKey('affiliate-terms.page.tocLabel')).toBe(true)
expect(hasKey('affiliate-terms.page.effectiveDateLabel')).toBe(true)
})
it('does not include any internal-only "Competitive analysis" or "Open questions" keys', () => {
const internalRegex = /(competitive-analysis|open-questions|legal-review)/
const leaks = translationKeys.filter(
(key) => key.startsWith(PREFIX) && internalRegex.test(key)
)
expect(leaks).toEqual([])
})
it('exposes affiliate terms at the canonical /affiliates/terms path regardless of locale', () => {
// Guards against re-introducing /zh-CN/affiliates/terms, which would
// serve an unreviewed translation of legal-reviewed copy. See the
// comment on LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
expect(getRoutes('en').affiliateTerms).toBe('/affiliates/terms')
expect(getRoutes('zh-CN').affiliateTerms).toBe('/affiliates/terms')
})
})

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
locale?: Locale
}>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
>
<h2
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const {
locale = 'en',
modelName,
ctaHref,
videoSrc,
videoAriaLabel
} = defineProps<{
locale?: Locale
modelName: string
ctaHref: string
videoSrc: string
videoAriaLabel?: string
}>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
<h1
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
}}
<span class="text-primary-comfy-yellow">ComfyUI</span>
{{
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
}}
</h1>
<p
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-10 px-8 py-4 uppercase"
>
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
</BrandButton>
<div class="mt-16 w-full max-w-5xl">
<video
:src="videoSrc"
:aria-label="videoAriaLabel || undefined"
:aria-hidden="videoAriaLabel ? undefined : true"
autoplay
loop
muted
playsinline
preload="metadata"
class="rounded-4.5xl size-full object-cover"
/>
</div>
</section>
</template>

View File

@@ -2,6 +2,7 @@
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
@@ -115,6 +116,8 @@ const plans: PricingPlan[] = [
const standardPlans = plans.filter((p) => !p.isEnterprise)
const enterprisePlan = plans.find((p) => p.isEnterprise)!
const activePlanIndex = ref(0)
</script>
<template>
@@ -131,7 +134,28 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</p>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"
:class="
cn(
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
activePlanIndex === index
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
)
"
@click="activePlanIndex = index"
>
<span class="ppformula-text-center">
{{ t(plan.labelKey, locale) }}
</span>
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<div
:class="
cn(
@@ -249,9 +273,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</PricingTierCard>
</div>
<!-- Mobile: stacked plans -->
<div class="flex flex-col gap-8 lg:hidden">
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
<!-- Mobile: single plan view -->
<div class="lg:hidden">
<div
v-for="(plan, index) in plans"
:key="plan.id"
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
>
<!-- Main info card -->
<div class="bg-transparency-white-t4 rounded-3xl p-6">
<!-- Label + badge -->

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CheckIcon from '../icons/CheckIcon.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -95,9 +94,12 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CheckIcon
<img
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
src="/icons/check.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}

View File

@@ -11,12 +11,14 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="max-w-9xl relative mx-auto mb-12 flex flex-col items-center overflow-hidden px-4 md:flex-row md:overflow-visible md:pt-20 lg:items-center lg:space-x-20"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-visible lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
<div
class="aspect-square w-4/5 max-w-md scale-125 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-10 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
class="block size-full overflow-visible"
viewBox="50 50 900 900"
fill="none"
aria-hidden="true"
@@ -376,7 +378,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</div>
<!-- Text -->
<div class="relative z-10 lg:flex-1">
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[25%] lg:px-20 lg:py-14"
>
<ProductHeroBadge text="CLOUD" />
<h1
@@ -386,7 +390,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-lg text-sm lg:mt-6 lg:text-base"
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
>
{{ t('cloud.hero.subtitle', locale) }}
</p>

View File

@@ -3,10 +3,7 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
@@ -15,64 +12,32 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const { downloadUrl, platform } = useDownloadUrl()
const ICONS = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
interface ButtonSpec {
key: string
href: string
icon: string
ariaLabel?: string
}
const buttons = computed<ButtonSpec[]>(() => {
if (platform.value) {
return [
{
key: platform.value,
href: downloadUrl.value,
icon: ICONS[platform.value]
}
]
const iconSrc = computed(() => {
switch (platform.value) {
case 'mac':
return '/icons/os/apple.svg'
case 'windows':
return '/icons/os/windows.svg'
default:
return undefined
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
}
]
}
return []
})
</script>
<template>
<BrandButton
v-for="btn in buttons"
:key="btn.key"
:href="btn.href"
v-show="platform"
:href="downloadUrl"
target="_blank"
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<img
:src="btn.icon"
v-if="iconSrc"
:src="iconSrc"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"

View File

@@ -168,7 +168,7 @@ onUnmounted(() => {
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-550/800 w-4/5 max-w-xs self-center overflow-visible md:max-w-sm lg:pointer-events-none lg:z-1 lg:-mr-12 lg:max-w-md lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
@@ -283,16 +283,16 @@ onUnmounted(() => {
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
>
<ProductHeroBadge text="DESKTOP" />
<ProductHeroBadge />
<h1
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
</p>
<h2
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="mt-24 w-full">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
@@ -180,15 +180,14 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
>
<!-- <span class="lg:hidden"> -->
{{ t('cloud.aiModels.ctaMobile', locale) }}
<!-- </span> -->
<!-- <span class="hidden lg:inline">{{
<span class="lg:hidden">{{
t('cloud.aiModels.ctaMobile', locale)
}}</span>
<span class="hidden lg:inline">{{
t('cloud.aiModels.ctaDesktop', locale)
}}</span> -->
}}</span>
</BrandButton>
</div>
</section>

View File

@@ -12,7 +12,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<p
class="text-primary-comfy-canvas relative z-10 text-sm font-semibold lg:text-sm lg:font-normal"
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>
<span class="whitespace-nowrap">
{{ t('download.cloud.prefix', locale) }}

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
@@ -24,8 +24,6 @@ function detectPlatform(ua: string): DetectedPlatform {
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)
const downloadUrl = computed(() => {
if (platform.value === 'windows') return downloadUrls.windows
@@ -33,16 +31,9 @@ export function useDownloadUrl() {
return externalLinks.github
})
const showFallback = computed(
() => detected.value && !platform.value && !isMobileUa.value
)
onMounted(() => {
const ua = navigator.userAgent.toLowerCase()
isMobileUa.value = isMobile(ua)
platform.value = detectPlatform(ua)
detected.value = true
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
})
return { downloadUrl, platform, showFallback }
return { downloadUrl, platform }
}

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-imagine': {
'grok-image': {
hubSlug: 'grok',
featured: false
},

View File

@@ -12,32 +12,15 @@ const baseRoutes = {
careers: '/careers',
customers: '/customers',
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
// Routes that are served only at their canonical path regardless of the
// active locale. Localized variants of these routes intentionally do not
// exist, so getRoutes(<non-en>) must not prefix them — emitting
// /zh-CN/<route> would produce a dead link.
//
// affiliateTerms: legal-reviewed English-only document. See the comment
// header in src/pages/affiliates/terms.astro and the affiliate-terms i18n
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService'
])
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
@@ -45,13 +28,12 @@ export function getRoutes(locale: Locale = 'en'): Routes {
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
export const externalLinks = {
affiliateApplicationForm: 'https://forms.gle/RS8L2ttcuGap4Q1v6',
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',

View File

@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
defineCollection: (config: unknown) => config
}))
vi.mock('astro/loaders', () => ({
glob: () => ({})
}))
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
const validEntry = {
order: 1,
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
href: 'https://www.thinkdiffusion.com/studio'
}
describe('gallerySchema', () => {
it('accepts a valid entry', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEntry
const result = gallerySchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('defaults visible to true when omitted', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(true)
}
})
it('preserves an explicit visible: false', () => {
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(false)
}
})
it('rejects an invalid URL in image/video/href', () => {
const result = gallerySchema.safeParse({
...validEntry,
href: 'not a url'
})
expect(result.success).toBe(false)
})
})
const validEvent = {
order: 1,
label: 'Live Stream:',
title: 'Zero to Node: Building Your First Workflow',
cta: 'Link',
href: 'https://example.com/event'
}
describe('eventsSchema', () => {
it('accepts a valid entry', () => {
const result = eventsSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEvent
const result = eventsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('accepts "#" as a placeholder href', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
expect(result.success).toBe(true)
})
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
expect(result.success).toBe(false)
})
})
const validTutorial = {
order: 1,
tags: ['Partner Nodes', 'Image To Video'],
title: 'Cleanplate Walkthrough',
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
}
describe('tutorialsSchema', () => {
it('accepts a valid entry', () => {
const result = tutorialsSchema.safeParse(validTutorial)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validTutorial
const result = tutorialsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('rejects an invalid videoSrc URL', () => {
const result = tutorialsSchema.safeParse({
...validTutorial,
videoSrc: 'not a url'
})
expect(result.success).toBe(false)
})
})

View File

@@ -1,78 +0,0 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
export const gallerySchema = z.object({
order: z.number().int(),
image: z.string().url().optional(),
video: z.string().url().optional(),
title: z.string(),
userAlias: z.string(),
teamAlias: z.string(),
tool: z.string(),
href: z.string().url().optional(),
objectPosition: z.string().optional(),
objectFit: z.string().optional(),
visible: z.boolean().default(true)
})
export type GalleryItem = z.infer<typeof gallerySchema>
export const eventsSchema = z.object({
order: z.number().int(),
label: z.string(),
title: z.string(),
cta: z.string(),
href: z.string().url().or(z.literal('#'))
})
export type EventItem = z.infer<typeof eventsSchema>
export const tutorialsSchema = z.object({
order: z.number().int(),
tags: z.array(z.string()),
title: z.string(),
videoSrc: z.string().url(),
href: z.string().url().optional(),
poster: z.string().url().optional(),
posterTime: z.number().optional()
})
export type LearningTutorial = z.infer<typeof tutorialsSchema>
export type ResolvedTutorial = LearningTutorial & { slug: string }
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
// config uses elsewhere. Strip the file extension to keep the path — and
// therefore the locale prefix — verbatim.
const preservePathId = ({ entry }: { entry: string }): string =>
entry.replace(/\.[^.]+$/, '')
const gallery = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/gallery',
generateId: preservePathId
}),
schema: gallerySchema
})
const events = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/events',
generateId: preservePathId
}),
schema: eventsSchema
})
const tutorials = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/tutorials',
generateId: preservePathId
}),
schema: tutorialsSchema
})
export const collections = { gallery, events, tutorials }

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "Event 1",
"title": "Lorem ipsum dollar sita met",
"cta": "London, UK",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "Event 2",
"title": "Lorem ipsum dollar sita met",
"cta": "San Francisco",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "Live Stream:",
"title": "Zero to Node: Building Your First Workflow",
"cta": "Link",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "活动 1",
"title": "此处为活动描述的占位文本",
"cta": "英国伦敦",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "活动 2",
"title": "此处为活动描述的占位文本",
"cta": "旧金山",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "直播:",
"title": "从零到节点:构建你的第一个工作流",
"cta": "链接",
"href": "#"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,11 +0,0 @@
{
"order": 21,
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
"title": "Amber Passage",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"objectPosition": "bottom",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,10 +0,0 @@
{
"order": 23,
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
"title": "Midnight Umami",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,10 +0,0 @@
{
"order": 20,
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
"title": "Milos Little Wonder",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,10 +0,0 @@
{
"order": 22,
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
"title": "Neon Revenant",
"userAlias": "Eric Solorio",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.instagram.com/enigmatic_e",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

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