Compare commits
59 Commits
bl/fix-qpo
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842e3d7541 | ||
|
|
c406042215 | ||
|
|
395b0a1c89 | ||
|
|
6068571b35 | ||
|
|
e37f168eaa | ||
|
|
b165b3f999 | ||
|
|
d7f9754393 | ||
|
|
48a3ea0e92 | ||
|
|
a8f8ba7580 | ||
|
|
966659b303 | ||
|
|
a95dab2f59 | ||
|
|
5f90bacb73 | ||
|
|
84319bea13 | ||
|
|
f076106ca5 | ||
|
|
d7fa853c06 | ||
|
|
07f881fc14 | ||
|
|
e14b5c6f3f | ||
|
|
065650b3bf | ||
|
|
a58f927871 | ||
|
|
c304f206a6 | ||
|
|
75ea646090 | ||
|
|
a670944a05 | ||
|
|
282b8cf819 | ||
|
|
70bc8dc6e6 | ||
|
|
ac56ecf009 | ||
|
|
e97cca9e4a | ||
|
|
49a7b7b558 | ||
|
|
8d82944441 | ||
|
|
a0f4feb111 | ||
|
|
d4be483c03 | ||
|
|
8d0b21e9e8 | ||
|
|
69858538d0 | ||
|
|
001b132b0c | ||
|
|
56b05c0fd5 | ||
|
|
403353ac77 | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 | ||
|
|
c8b5589768 | ||
|
|
b4b95980da | ||
|
|
8c0af36c4a | ||
|
|
78a8d6f8fc | ||
|
|
cc41e3e1ac | ||
|
|
f994673dd1 | ||
|
|
444dc3fccd | ||
|
|
ed028a88be | ||
|
|
26cd975c1d | ||
|
|
c2968422e6 | ||
|
|
5acd76cb6d | ||
|
|
bc885f383c | ||
|
|
fc4d44c3db | ||
|
|
7f25d28b71 | ||
|
|
67b884d0f7 | ||
|
|
05efee07ce | ||
|
|
bc212e8a19 | ||
|
|
4199ab2f01 |
@@ -15,6 +15,11 @@ reviews:
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
# Explicitly disable the built-in docstring coverage check, which is
|
||||
# enabled via organization-level settings. This repo opts out at the
|
||||
# repo level without affecting other org repos.
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
|
||||
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -85,6 +85,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
63
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read # 'read' is enough because signatures live in a REMOTE repo
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT required to write to the centralized signatures repo.
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
# Where the CLA document lives (shown to contributors)
|
||||
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
|
||||
|
||||
# Centralized signature storage
|
||||
remote-organization-name: comfy-org
|
||||
remote-repository-name: comfy-cla
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
🎉 Thank you for your contribution, we really appreciate it! 🎉
|
||||
|
||||
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
|
||||
|
||||
- Confirm that you own your contribution.
|
||||
- Keep the right to reuse your own code.
|
||||
- Grant us a copyright license to include and share it within our projects.
|
||||
|
||||
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
|
||||
|
||||
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
|
||||
|
||||
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.
|
||||
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
name: Publish Desktop Bridge Types
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (e.g., 0.1.2)'
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
description: 'npm dist-tag to use'
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to checkout (commit SHA, tag, or branch)'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish_desktop_bridge_types:
|
||||
name: Publish @comfyorg/comfyui-desktop-bridge-types
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
|
||||
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
|
||||
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Determine ref to checkout
|
||||
id: resolve_ref
|
||||
env:
|
||||
REF: ${{ inputs.ref }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
if ! git check-ref-format --allow-onelevel "$REF"; then
|
||||
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.resolve_ref.outputs.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Verify package
|
||||
id: pkg
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
|
||||
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
|
||||
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
|
||||
if [ "$VERSION" != "$INPUT_VERSION" ]; then
|
||||
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if version already on npm
|
||||
id: check_npm
|
||||
env:
|
||||
NAME: ${{ steps.pkg.outputs.name }}
|
||||
VER: ${{ steps.pkg.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATUS=0
|
||||
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
|
||||
else
|
||||
if echo "$OUTPUT" | grep -q "E404"; then
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error title=Registry lookup failed::$OUTPUT" >&2
|
||||
exit "$STATUS"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Publish package
|
||||
if: steps.check_npm.outputs.exists == 'false'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DIST_TAG: ${{ inputs.dist_tag }}
|
||||
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
|
||||
working-directory: packages/comfyui-desktop-bridge-types
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
@@ -92,9 +92,7 @@ jobs:
|
||||
make_latest: >-
|
||||
${{ github.event.pull_request.base.ref == 'main' &&
|
||||
needs.build.outputs.is_prerelease == 'false' }}
|
||||
draft: >-
|
||||
${{ github.event.pull_request.base.ref != 'main' ||
|
||||
needs.build.outputs.is_prerelease == 'true' }}
|
||||
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
prerelease: >-
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -78,6 +78,11 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
24
apps/website/components.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"font": "inter",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"pointer": true,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const TOP_LEVEL_LABELS = [
|
||||
'Products',
|
||||
'Pricing',
|
||||
'Community',
|
||||
'Company'
|
||||
] as const
|
||||
|
||||
test.describe('Desktop navigation @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
@@ -17,14 +24,10 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of [
|
||||
'PRODUCTS',
|
||||
'PRICING',
|
||||
'COMMUNITY',
|
||||
'RESOURCES',
|
||||
'COMPANY'
|
||||
]) {
|
||||
await expect(desktopLinks.getByText(label).first()).toBeVisible()
|
||||
for (const label of TOP_LEVEL_LABELS) {
|
||||
await expect(
|
||||
desktopLinks.getByText(label, { exact: true }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,11 +52,11 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const productsButton = desktopLinks.getByRole('button', {
|
||||
name: /PRODUCTS/i
|
||||
name: 'Products'
|
||||
})
|
||||
await productsButton.hover()
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
const dropdown = nav.getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Desktop',
|
||||
'Comfy Cloud',
|
||||
@@ -67,19 +70,20 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
test('moving mouse away closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
const viewport = page.viewportSize()
|
||||
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
@@ -105,11 +109,11 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
const menu = page.getByRole('dialog')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
|
||||
await expect(menu.getByText(label).first()).toBeVisible()
|
||||
for (const label of ['Products', 'Pricing', 'Community']) {
|
||||
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -118,24 +122,14 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
const menu = page.getByRole('dialog')
|
||||
await menu.getByRole('button', { name: 'Products' }).click()
|
||||
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('CTA buttons visible in mobile menu', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
@@ -25,12 +25,15 @@
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@lucide/vue": "catalog:",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -43,6 +46,7 @@
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ function toggle(index: number) {
|
||||
<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"
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
@@ -83,7 +83,7 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<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"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<!-- Desktop sticky nav -->
|
||||
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<CategoryNav
|
||||
:categories="categories"
|
||||
@@ -135,7 +135,7 @@ function scrollToSection(id: string) {
|
||||
>
|
||||
<h2
|
||||
v-if="section.hasTitle"
|
||||
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
|
||||
class="mb-6 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(key(section.id, 'title'), locale) }}
|
||||
</h2>
|
||||
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
|
||||
<!-- Paragraph -->
|
||||
<p
|
||||
v-if="block.type === 'paragraph'"
|
||||
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
|
||||
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
|
||||
v-html="t(key(section.id, `block.${i}`), locale)"
|
||||
/>
|
||||
|
||||
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-2"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
|
||||
@@ -187,7 +187,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3"
|
||||
class="flex items-start gap-3 text-primary-comfy-canvas"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
|
||||
@@ -205,7 +205,7 @@ function scrollToSection(id: string) {
|
||||
:alt="t(key(section.id, `block.${i}.alt`), locale)"
|
||||
class="w-full rounded-2xl object-cover"
|
||||
/>
|
||||
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.caption`), locale) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
|
||||
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
|
||||
>
|
||||
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
|
||||
</p>
|
||||
@@ -238,17 +238,17 @@ function scrollToSection(id: string) {
|
||||
<SectionLabel>
|
||||
{{ t(key(section.id, `block.${i}.label`), locale) }}
|
||||
</SectionLabel>
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
|
||||
<p class="mt-2 text-sm font-semibold text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.role`), locale) }}
|
||||
</p>
|
||||
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
|
||||
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.name2`), locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.role2`), locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<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-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
85
apps/website/src/components/common/HeaderMain/HeaderMain.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import { externalLinks, getRoutes } from '../../../config/routes.ts'
|
||||
import GitHubStarBadge from '../GitHubStarBadge.vue'
|
||||
import HeaderMainDesktop from './HeaderMainDesktop.vue'
|
||||
import HeaderMainMobile from './HeaderMainMobile.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en', githubStars = '' } = defineProps<{
|
||||
locale?: Locale
|
||||
githubStars?: string
|
||||
}>()
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const ctaButtons = [
|
||||
{
|
||||
prefix: t('nav.ctaDesktopPrefix', locale),
|
||||
core: t('nav.ctaDesktopCore', locale),
|
||||
ariaLabel: t('nav.downloadLocal', locale),
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
prefix: t('nav.ctaCloudPrefix', locale),
|
||||
core: t('nav.ctaCloudCore', locale),
|
||||
ariaLabel: t('nav.launchCloud', locale),
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
|
||||
aria-label="Comfy home"
|
||||
>
|
||||
<img
|
||||
src="/icons/logomark.svg"
|
||||
alt="Comfy"
|
||||
class="col-span-full row-span-full h-8"
|
||||
/>
|
||||
<div
|
||||
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
|
||||
>
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<HeaderMainDesktop :locale class="hidden lg:block" />
|
||||
<HeaderMainMobile :locale class="lg:hidden" />
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<Button
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
as="a"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'default' : 'outline'"
|
||||
:aria-label="cta.ariaLabel"
|
||||
>
|
||||
<span
|
||||
><span class="hidden xl:inline-block">{{ cta.prefix }} </span
|
||||
>{{ cta.core }}</span
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenu from '@/components/ui/navigation-menu/NavigationMenu.vue'
|
||||
import NavigationMenuContent from '@/components/ui/navigation-menu/NavigationMenuContent.vue'
|
||||
import NavigationMenuItem from '@/components/ui/navigation-menu/NavigationMenuItem.vue'
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
|
||||
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
|
||||
|
||||
import {
|
||||
isHrefActive,
|
||||
useCurrentPath
|
||||
} from '../../../composables/useCurrentPath'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
const currentPath = useCurrentPath()
|
||||
|
||||
function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
if (navItem.href) return isHrefActive(navItem.href, path)
|
||||
return (
|
||||
navItem.columns?.some((column) =>
|
||||
column.items.some((item) => isHrefActive(item.href, path))
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenu data-testid="desktop-nav-links">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem
|
||||
v-for="navItem in mainNavigation"
|
||||
:key="navItem.label"
|
||||
>
|
||||
<template v-if="navItem.columns?.length">
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
<NavFeaturedCard
|
||||
v-if="navItem.featured"
|
||||
:featured="navItem.featured"
|
||||
/>
|
||||
<NavColumn
|
||||
v-for="column in navItem.columns"
|
||||
:key="column.header"
|
||||
:column="column"
|
||||
:locale="locale"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
<NavigationMenuLink
|
||||
v-else
|
||||
as-child
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
:class="navigationMenuTriggerStyle()"
|
||||
>
|
||||
<a :href="navItem.href" class="ppformula-text-center">{{
|
||||
navItem.label
|
||||
}}</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import { getRoutes } from '../../../config/routes.ts'
|
||||
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const routes = getRoutes(locale)
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
|
||||
const isOpen = ref(false)
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeItem = computed(() =>
|
||||
mainNavigation.find(
|
||||
(item) => item.label === activeSection.value && item.columns
|
||||
)
|
||||
)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
lockScroll()
|
||||
} else {
|
||||
unlockScroll()
|
||||
activeSection.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isOpen.value) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Sheet v-model:open="isOpen">
|
||||
<SheetTrigger
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
|
||||
>
|
||||
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
|
||||
:close-label="t('nav.close', locale)"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{{ t('nav.mobileMenuDescription', locale) }}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
|
||||
>
|
||||
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
|
||||
<span class="sr-only">{{ t('nav.home', locale) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 flex-1 overflow-hidden">
|
||||
<!-- Top-level nav -->
|
||||
<nav
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 overflow-y-auto p-1',
|
||||
activeItem ? 'opacity-0' : ''
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
:inert="activeItem ? true : undefined"
|
||||
>
|
||||
<ul class="flex flex-col gap-y-8">
|
||||
<li v-for="item in mainNavigation" :key="item.label">
|
||||
<Button
|
||||
:as="item.columns ? 'button' : 'a'"
|
||||
variant="navMuted"
|
||||
:type="item.columns ? 'button' : undefined"
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Drill-down sub-panel -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
|
||||
:class="
|
||||
activeItem
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'
|
||||
"
|
||||
:inert="activeItem ? undefined : true"
|
||||
:aria-hidden="!activeItem"
|
||||
>
|
||||
<div class="size-full overflow-y-auto py-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<template #prepend>
|
||||
<ChevronLeft />
|
||||
</template>
|
||||
{{ t('nav.back', locale) }}
|
||||
</Button>
|
||||
|
||||
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
|
||||
<div
|
||||
v-for="column in activeItem.columns"
|
||||
:key="column.header"
|
||||
class="flex flex-col gap-y-3"
|
||||
>
|
||||
<p
|
||||
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="link in column.items"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
variant="nav"
|
||||
as="a"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<NavLinkContent :item="link" :locale="locale" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
36
apps/website/src/components/common/HeaderMain/NavColumn.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
|
||||
import { isHrefActive } from '../../../composables/useCurrentPath'
|
||||
import type { NavColumn } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
|
||||
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="flex flex-col space-y-4">
|
||||
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<ul class="flex flex-col">
|
||||
<li v-for="item in column.items" :key="item.label">
|
||||
<NavigationMenuLink
|
||||
as-child
|
||||
:active="isHrefActive(item.href, currentPath)"
|
||||
class="hover:bg-transparency-white-t4"
|
||||
>
|
||||
<a
|
||||
:href="item.href"
|
||||
:target="item.external ? '_blank' : undefined"
|
||||
:rel="item.external ? 'noopener noreferrer' : undefined"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<NavLinkContent :item="item" :locale="locale" />
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import ButtonPill from '@/components/ui/button-pill/ButtonPill.vue'
|
||||
|
||||
import type { NavFeatured } from '../../../data/mainNavigation'
|
||||
|
||||
defineProps<{ featured: NavFeatured }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="shrink-0">
|
||||
<a
|
||||
:href="featured.cta.href"
|
||||
:aria-label="featured.cta.ariaLabel"
|
||||
class="group/pill-trigger relative block"
|
||||
>
|
||||
<img
|
||||
class="aspect-4/3 w-62 max-w-none rounded-xl"
|
||||
:src="featured.imageSrc"
|
||||
:alt="featured.imageAlt ?? ''"
|
||||
/>
|
||||
<p class="mt-4 font-extrabold uppercase">
|
||||
{{ featured.title }}
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<ButtonPill as="span" icon-position="left" variant="ghost">
|
||||
{{ featured.cta.label }}
|
||||
</ButtonPill>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import type { NavColumnItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import type { NavLink } from './NavDesktopLink.vue'
|
||||
|
||||
interface CtaLink {
|
||||
label: string
|
||||
href: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
open = false,
|
||||
navigating = false,
|
||||
links = [],
|
||||
ctaLinks = [],
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
open?: boolean
|
||||
navigating?: boolean
|
||||
links?: NavLink[]
|
||||
ctaLinks?: CtaLink[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | undefined>()
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeSectionItems = computed(
|
||||
() => links.find((l) => l.label === activeSection.value)?.items
|
||||
)
|
||||
|
||||
function onNavigate() {
|
||||
activeSection.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
const focusable = [...menu.querySelectorAll<HTMLElement>(FOCUSABLE)]
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
lockScroll()
|
||||
await nextTick()
|
||||
const menu = menuRef.value
|
||||
const firstFocusable = menu?.querySelector<HTMLElement>(FOCUSABLE)
|
||||
firstFocusable?.focus()
|
||||
menu?.addEventListener('keydown', trapFocus)
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
unlockScroll({ skipRestore: navigating })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
if (open) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="open"
|
||||
id="site-mobile-menu"
|
||||
ref="menuRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:inert="!open"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
|
||||
>
|
||||
<!-- Main list -->
|
||||
<template v-if="!activeSection">
|
||||
<div class="flex flex-1 flex-col gap-8">
|
||||
<template v-for="link in links" :key="link.label">
|
||||
<button
|
||||
v-if="link.items"
|
||||
class="text-primary-comfy-canvas text-left text-3xl font-medium"
|
||||
@click="activeSection = link.label"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="link.href"
|
||||
class="text-primary-comfy-canvas text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<BrandButton
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'solid' : 'outline'"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Drill-down sub-menu -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<button
|
||||
class="text-primary-comfy-yellow mb-6 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="bg-primary-comfy-yellow size-3 -translate-y-px rotate-180"
|
||||
style="
|
||||
mask: url('/icons/arrow-right.svg') center / contain no-repeat;
|
||||
"
|
||||
/>
|
||||
{{ t('nav.back', locale) }}
|
||||
</button>
|
||||
|
||||
<p class="text-primary-warm-gray mb-8 text-sm font-bold uppercase">
|
||||
{{ activeSection }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-8 pl-2">
|
||||
<a
|
||||
v-for="item in activeSectionItems"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="text-primary-comfy-canvas flex items-center gap-3 text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-semibold"
|
||||
>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type NavLink = {
|
||||
label: string
|
||||
href?: string
|
||||
items?: NavDropdownItem[]
|
||||
}
|
||||
|
||||
const {
|
||||
link,
|
||||
currentPath,
|
||||
isOpen = false
|
||||
} = defineProps<{
|
||||
link: NavLink
|
||||
currentPath: string
|
||||
isOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open', label: string): void
|
||||
(e: 'close'): void
|
||||
(e: 'toggle', label: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="link.items?.length && emit('open', link.label)"
|
||||
@mouseleave="emit('close')"
|
||||
@focusin="link.items?.length && emit('open', link.label)"
|
||||
@focusout="emit('close')"
|
||||
>
|
||||
<button
|
||||
v-if="link.items?.length"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group flex cursor-pointer items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
|
||||
link.items.some((item) => currentPath === item.href)
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="isOpen"
|
||||
@click="emit('toggle', link.label)"
|
||||
>
|
||||
{{ link.label }}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'text-base leading-none transition-colors',
|
||||
link.items.some((item) => currentPath === item.href)
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas group-hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
v-else
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
|
||||
currentPath === link.href
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="link.items?.length"
|
||||
v-show="isOpen"
|
||||
data-testid="nav-dropdown"
|
||||
class="bg-transparency-ink-t80 absolute top-full left-0 w-max rounded-xl p-2 shadow-lg backdrop-blur-2xl backdrop-saturate-150"
|
||||
>
|
||||
<a
|
||||
v-for="item in link.items"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
:aria-current="currentPath === item.href ? 'page' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-sm p-2 text-xs font-medium tracking-wide transition-colors',
|
||||
currentPath === item.href
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:bg-transparency-white-t4 hover:text-white'
|
||||
)
|
||||
"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
|
||||
>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="ml-auto size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints,
|
||||
useEventListener,
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import GitHubStarBadge from './GitHubStarBadge.vue'
|
||||
import MobileMenu from './MobileMenu.vue'
|
||||
import NavDesktopLink from './NavDesktopLink.vue'
|
||||
import type { NavLink } from './NavDesktopLink.vue'
|
||||
|
||||
const { locale = 'en', githubStars = '' } = defineProps<{
|
||||
locale?: Locale
|
||||
githubStars?: string
|
||||
}>()
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
badge: t('nav.badgeNew', locale)
|
||||
},
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
|
||||
]
|
||||
},
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.comfyHub', locale),
|
||||
href: externalLinks.workflows,
|
||||
badge: t('nav.badgeNew', locale)
|
||||
},
|
||||
{ label: t('nav.gallery', locale), href: routes.gallery }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.resources', locale),
|
||||
items: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.company', locale),
|
||||
items: [
|
||||
{ label: t('nav.aboutUs', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.customerStories', locale), href: routes.customers }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: t('nav.downloadLocal', locale),
|
||||
prefix: 'DOWNLOAD',
|
||||
core: 'DESKTOP',
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
label: t('nav.launchCloud', locale),
|
||||
prefix: 'LAUNCH',
|
||||
core: 'CLOUD',
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
|
||||
const currentPath = ref('')
|
||||
const openDesktopDropdown = ref<string | null>(null)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isNavigating = ref(false)
|
||||
const hamburgerRef = ref<HTMLButtonElement | undefined>()
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
hamburgerRef.value?.focus()
|
||||
}
|
||||
|
||||
function toggleDesktopDropdown(label: string) {
|
||||
openDesktopDropdown.value = openDesktopDropdown.value === label ? null : label
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMobileMenu()
|
||||
openDesktopDropdown.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onNavigate() {
|
||||
isNavigating.value = true
|
||||
closeMobileMenu()
|
||||
openDesktopDropdown.value = null
|
||||
currentPath.value = window.location.pathname
|
||||
await nextTick()
|
||||
isNavigating.value = false
|
||||
}
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isDesktop = breakpoints.greaterOrEqual('lg')
|
||||
|
||||
whenever(isDesktop, () => {
|
||||
mobileMenuOpen.value = false
|
||||
// Don't focus hamburger when transitioning to desktop — it's hidden
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
currentPath.value = window.location.pathname
|
||||
useEventListener(document, 'keydown', onKeydown)
|
||||
useEventListener(document, 'astro:after-swap', onNavigate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MobileMenu
|
||||
:open="mobileMenuOpen"
|
||||
:navigating="isNavigating"
|
||||
:links="navLinks"
|
||||
:cta-links="ctaButtons"
|
||||
:locale="locale"
|
||||
@close="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
|
||||
aria-label="Comfy home"
|
||||
>
|
||||
<img
|
||||
src="/icons/logomark.svg"
|
||||
alt="Comfy"
|
||||
class="col-span-full row-span-full h-8"
|
||||
/>
|
||||
<div
|
||||
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
|
||||
>
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div
|
||||
data-testid="desktop-nav-links"
|
||||
class="hidden items-center gap-[clamp(1rem,2.5vw,2.5rem)] lg:flex"
|
||||
>
|
||||
<NavDesktopLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.label"
|
||||
:link="link"
|
||||
:current-path="currentPath"
|
||||
:is-open="openDesktopDropdown === link.label"
|
||||
@open="openDesktopDropdown = $event"
|
||||
@close="openDesktopDropdown = null"
|
||||
@toggle="toggleDesktopDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<BrandButton
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'solid' : 'outline'"
|
||||
size="nav"
|
||||
:aria-label="cta.label"
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-0 overflow-hidden align-bottom transition-[max-width] duration-300 ease-in-out xl:max-w-28"
|
||||
aria-hidden="true"
|
||||
>{{ cta.prefix }} </span
|
||||
>{{ cta.core }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
ref="hamburgerRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-xl lg:hidden',
|
||||
mobileMenuOpen
|
||||
? 'border-primary-comfy-yellow border-2 bg-transparent'
|
||||
: 'bg-primary-comfy-yellow'
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<img
|
||||
v-if="!mobileMenuOpen"
|
||||
src="/icons/breadthumb.svg"
|
||||
alt=""
|
||||
class="h-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/icons/close.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
@@ -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>
|
||||
@@ -58,13 +58,13 @@ function handleLogoLoad() {
|
||||
</SectionLabel>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
|
||||
class="mt-4 text-4xl/tight font-light text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t('customers.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
ref="bodyRef"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-lg text-base"
|
||||
class="mt-6 max-w-lg text-base text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('customers.hero.body', locale) }}
|
||||
</p>
|
||||
@@ -72,7 +72,12 @@ function handleLogoLoad() {
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
|
||||
<div
|
||||
id="hero-video"
|
||||
ref="videoRef"
|
||||
class="max-w-9xl mx-auto scroll-mt-24 px-4 pb-20 lg:scroll-mt-36 lg:px-20 lg:pb-40"
|
||||
>
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/blackmath/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"
|
||||
|
||||
@@ -35,7 +35,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
{{ t(story.category, locale) }}
|
||||
</span>
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
</h3>
|
||||
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -35,18 +35,18 @@ const {
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<a :href="href" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
|
||||
</span>
|
||||
|
||||
@@ -21,7 +21,7 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -37,18 +37,18 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
|
||||
@@ -53,7 +53,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-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,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-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
20
apps/website/src/components/icons/BreadthumbIcon.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<path
|
||||
d="M20 1C20 1.55228 19.5523 2 19 2H17.5C16.6716 2 16 2.67157 16 3.5C16 4.32843 16.6716 5 17.5 5H19C19.5523 5 20 5.44772 20 6C20 6.55228 19.5523 7 19 7H7.5C6.67157 7 6 7.67157 6 8.5C6 9.32843 6.67157 10 7.5 10H19C19.5523 10 20 10.4477 20 11C20 11.5523 19.5523 12 19 12H1C0.447715 12 0 11.5523 0 11C0 10.4477 0.447715 10 1 10H2.5C3.32843 10 4 9.32843 4 8.5C4 7.67157 3.32843 7 2.5 7H1C0.447715 7 0 6.55228 0 6C0 5.44772 0.447715 5 1 5H12.5C13.3284 5 14 4.32843 14 3.5C14 2.67157 13.3284 2 12.5 2H1C0.447716 2 0 1.55228 0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
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"
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -76,13 +76,14 @@ const activeTutorial = () =>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
@@ -98,7 +99,7 @@ const activeTutorial = () =>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
|
||||
40
apps/website/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { BadgeVariants } from '.'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
} = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
size?: BadgeVariants['size']
|
||||
class?: string
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
</slot>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<slot name="append">
|
||||
<component :is="appendIcon" v-if="appendIcon" />
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
24
apps/website/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MaskRevealButton from './MaskRevealButton.vue'
|
||||
import ButtonMask from './ButtonMask.vue'
|
||||
|
||||
const meta: Meta<typeof MaskRevealButton> = {
|
||||
title: 'Website/Common/MaskRevealButton',
|
||||
component: MaskRevealButton,
|
||||
const meta: Meta<typeof ButtonMask> = {
|
||||
title: 'Website/UI/ButtonMask',
|
||||
component: ButtonMask,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
@@ -12,22 +12,19 @@ const meta: Meta<typeof MaskRevealButton> = {
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
as: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
options: ['button', 'a']
|
||||
},
|
||||
asChild: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
options: ['default', 'lg', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
@@ -41,57 +38,57 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
|
||||
template: `<ButtonMask v-bind="args">Try Workflow</ButtonMask>`
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
args: { as: 'a', href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Read More</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
args: { as: 'a', href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Go Back</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
export const DefaultSolid: Story = {
|
||||
args: { as: 'a', href: '#', size: 'default' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Try Workflow</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
args: { as: 'a', href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
|
||||
template: `<ButtonMask v-bind="args">Let's Collaborate</ButtonMask>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<MaskRevealButton v-bind="args">
|
||||
<ButtonMask v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -106,57 +103,53 @@ export const WithCustomIcon: Story = {
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</ButtonMask>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LabelVisible: Story = {
|
||||
args: { href: '#', hideLabel: false },
|
||||
args: { as: 'a', href: '#', hideLabel: false },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template:
|
||||
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Always Visible</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Unavailable</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
|
||||
<ButtonMask as="a" href="#" variant="solid" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" variant="solid" size="lg">Large</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
|
||||
<ButtonMask as="a" href="#" variant="ghost" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" variant="ghost" size="lg">Large</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
|
||||
<ButtonMask as="a" href="#" iconPosition="left" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" iconPosition="left" size="lg">Large</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
apps/website/src/components/ui/button-mask/ButtonMask.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { ButtonMaskVariants } from '.'
|
||||
import {
|
||||
BUTTON_MASK_LABEL_CLASS,
|
||||
buttonMaskBadgeVariants,
|
||||
buttonMaskVariants
|
||||
} from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonMaskVariants['variant']
|
||||
size?: ButtonMaskVariants['size']
|
||||
iconPosition?: ButtonMaskVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = true,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button-mask"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonMaskVariants({ variant, size, iconPosition }), className)"
|
||||
>
|
||||
<span
|
||||
:data-icon-position="iconPosition ?? 'right'"
|
||||
:data-hidden="hideLabel ? 'true' : 'false'"
|
||||
:class="BUTTON_MASK_LABEL_CLASS"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="buttonMaskBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<ChevronRight class="size-4" :stroke-width="2" />
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
94
apps/website/src/components/ui/button-mask/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export { default as ButtonMask } from './ButtonMask.vue'
|
||||
|
||||
export const buttonMaskVariants = cva({
|
||||
base: 'group/button-mask relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
|
||||
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: 'default', iconPosition: 'right', class: 'ps-12 pe-4' },
|
||||
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
|
||||
{ size: 'default', iconPosition: 'left', class: 'ps-4 pe-12' },
|
||||
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const buttonMaskBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
default: 'size-8',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover/button-mask:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover/button-mask:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover/button-mask:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover/button-mask:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const BUTTON_MASK_LABEL_CLASS = [
|
||||
'ppformula-text-center relative inline-block align-baseline',
|
||||
'[will-change:mask-size,-webkit-mask-size]',
|
||||
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
|
||||
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
|
||||
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
|
||||
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
|
||||
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
|
||||
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
|
||||
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
|
||||
'group-hover/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
|
||||
'group-focus-visible/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
|
||||
].join(' ')
|
||||
|
||||
export type ButtonMaskVariants = VariantProps<typeof buttonMaskVariants>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PillButton from './PillButton.vue'
|
||||
import ButtonPill from './ButtonPill.vue'
|
||||
|
||||
const meta: Meta<typeof PillButton> = {
|
||||
title: 'Website/Common/PillButton',
|
||||
component: PillButton,
|
||||
const meta: Meta<typeof ButtonPill> = {
|
||||
title: 'Website/UI/ButtonPill',
|
||||
component: ButtonPill,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
@@ -12,22 +12,19 @@ const meta: Meta<typeof PillButton> = {
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
as: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
options: ['button', 'a']
|
||||
},
|
||||
asChild: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
options: ['default', 'lg', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
@@ -41,57 +38,57 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AsAnchor: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
|
||||
})
|
||||
}
|
||||
|
||||
export const AsButton: Story = {
|
||||
args: { type: 'button' },
|
||||
args: { as: 'button', type: 'button' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Submit</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Submit</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
args: { as: 'a', href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Read More</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Read More</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
export const DefaultSolid: Story = {
|
||||
args: { as: 'a', href: '#', size: 'default' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
args: { as: 'a', href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<PillButton v-bind="args">
|
||||
<ButtonPill v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -106,57 +103,55 @@ export const WithCustomIcon: Story = {
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</PillButton>
|
||||
</ButtonPill>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
args: { as: 'a', href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Go Back</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Go Back</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const RevealLabelOnHover: Story = {
|
||||
args: { href: '#', hideLabel: true },
|
||||
args: { as: 'a', href: '#', hideLabel: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Unavailable</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Unavailable</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
|
||||
<ButtonPill as="a" href="#" variant="solid" size="default">Default</ButtonPill>
|
||||
<ButtonPill as="a" href="#" variant="solid" size="lg">Large</ButtonPill>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
|
||||
<ButtonPill as="a" href="#" variant="ghost" size="default">Default</ButtonPill>
|
||||
<ButtonPill as="a" href="#" variant="ghost" size="lg">Large</ButtonPill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
61
apps/website/src/components/ui/button-pill/ButtonPill.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { ButtonPillVariants } from '.'
|
||||
import { buttonPillBadgeVariants, buttonPillVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonPillVariants['variant']
|
||||
size?: ButtonPillVariants['size']
|
||||
iconPosition?: ButtonPillVariants['iconPosition']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button-pill"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonPillVariants({ variant, size, iconPosition }), className)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'ppformula-text-center relative leading-none transition-all duration-500'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="buttonPillBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<ChevronRight class="size-4" :stroke-width="2" />
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
102
apps/website/src/components/ui/button-pill/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonPillVariants = cva({
|
||||
base: 'group/button-pill isolate relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
|
||||
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'ps-6 pe-14 group-hover/pill-trigger:ps-14 group-hover/pill-trigger:pe-6 hover:ps-14 hover:pe-6'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'ps-8 pe-16 group-hover/pill-trigger:ps-16 group-hover/pill-trigger:pe-8 hover:ps-16 hover:pe-8'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'ps-14 pe-6 group-hover/pill-trigger:ps-6 group-hover/pill-trigger:pe-14 hover:ps-6 hover:pe-14'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'ps-16 pe-8 group-hover/pill-trigger:ps-8 group-hover/pill-trigger:pe-16 hover:ps-8 hover:pe-16'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const buttonPillBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
default: 'size-8',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'right-1 group-hover/button-pill:right-[calc(100%-36px)] group-hover/pill-trigger:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'right-1 group-hover/button-pill:right-[calc(100%-52px)] group-hover/pill-trigger:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'left-1 group-hover/button-pill:left-[calc(100%-36px)] group-hover/pill-trigger:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'left-1 group-hover/button-pill:left-[calc(100%-52px)] group-hover/pill-trigger:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export type ButtonPillVariants = VariantProps<typeof buttonPillVariants>
|
||||
50
apps/website/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
</slot>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<slot name="append">
|
||||
<component :is="appendIcon" v-if="appendIcon" />
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
31
apps/website/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
"focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 aria-invalid:bg-destructive aria-invalid:hover:bg-destructive/90 inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-2xl text-sm font-bold tracking-wider whitespace-nowrap transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from 'reka-ui'
|
||||
import { NavigationMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import NavigationMenuViewport from './NavigationMenuViewport.vue'
|
||||
|
||||
const {
|
||||
viewport = true,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
NavigationMenuRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
viewport?: boolean
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="navigation-menu"
|
||||
:data-viewport="viewport"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
<NavigationMenuViewport v-if="viewport" />
|
||||
</NavigationMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
NavigationMenuContentEmits,
|
||||
NavigationMenuContentProps
|
||||
} from 'reka-ui'
|
||||
import { NavigationMenuContent, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuContentProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuContentEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuContent
|
||||
data-slot="navigation-menu-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'top-0 left-0 w-full px-8 py-6 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:border-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-3xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:text-primary-comfy-canvas group-data-[viewport=false]/navigation-menu:shadow-sm group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItemProps } from 'reka-ui'
|
||||
import { NavigationMenuItem } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuItem
|
||||
data-slot="navigation-menu-item"
|
||||
v-bind="restProps"
|
||||
:class="cn('relative', className)"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuLinkEmits, NavigationMenuLinkProps } from 'reka-ui'
|
||||
import { NavigationMenuLink, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuLinkProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuLinkEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuLink
|
||||
data-slot="navigation-menu-link"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'data-active:text-primary-comfy-yellow focus:bg-transparency-white-t4 ring-primary-comfy-yellow outline-primary-comfy-yellow flex flex-col gap-1 rounded-xl p-2 text-sm transition-[color,box-shadow] hover:text-white focus:text-white focus-visible:ring-4 focus-visible:outline-1 data-active:bg-transparent data-active:hover:bg-transparent [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuLink>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuListProps } from 'reka-ui'
|
||||
import { NavigationMenuList, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuList
|
||||
data-slot="navigation-menu-list"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'group flex flex-1 list-none items-center justify-center gap-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuList>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { NavigationMenuTriggerProps } from 'reka-ui'
|
||||
import { NavigationMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { navigationMenuTriggerStyle } from './navigationMenuTriggerStyle'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
active,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
NavigationMenuTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
active?: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuTrigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:data-active="active ? '' : undefined"
|
||||
:class="cn(navigationMenuTriggerStyle(), 'group', className)"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="relative ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuViewportProps } from 'reka-ui'
|
||||
import { NavigationMenuViewport, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuViewportProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
|
||||
<NavigationMenuViewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'origin-top-center bg-primary-comfy-ink-light border-primary-comfy-ink-light relative left-(--reka-navigation-menu-viewport-left) mt-1.5 h-(--reka-navigation-menu-viewport-height) w-full overflow-hidden rounded-3xl border text-primary-comfy-canvas shadow-sm data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-(--reka-navigation-menu-viewport-width)',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const navigationMenuTriggerStyle = cva([
|
||||
'group font-formula inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-2xl px-4 py-3 text-sm font-extrabold tracking-wider text-primary-comfy-canvas uppercase transition-[color,box-shadow] outline-none',
|
||||
'hover:text-primary-warm-gray',
|
||||
'data-[state=open]:hover:text-primary-comfy-yellow data-[state=open]:text-primary-comfy-yellow data-[state=open]:focus:text-primary-comfy-yellow',
|
||||
'data-active:text-primary-comfy-yellow data-active:hover:text-primary-comfy-yellow',
|
||||
'focus:bg-accent focus-visible:ring-primary-comfy-yellow focus:text-accent-foreground focus-visible:ring-3 focus-visible:outline-1',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
])
|
||||
15
apps/website/src/components/ui/sheet/Sheet.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
12
apps/website/src/components/ui/sheet/SheetClose.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose data-slot="sheet-close" v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
62
apps/website/src/components/ui/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import { DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import SheetClose from './SheetClose.vue'
|
||||
import SheetOverlay from './SheetOverlay.vue'
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
closeLabel: string
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
side = 'right',
|
||||
closeLabel,
|
||||
class: classProp,
|
||||
...delegatedProps
|
||||
} = defineProps<SheetContentProps>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="
|
||||
cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-primary-comfy-ink transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
classProp
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<SheetClose
|
||||
class="focus:ring-primary-comfy-yellow/50 text-primary-comfy-yellow border-primary-comfy-yellow absolute top-4 right-4 rounded-xl border p-2 ring-offset-primary-comfy-ink transition-opacity focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-6" />
|
||||
<span class="sr-only">{{ closeLabel }}</span>
|
||||
</SheetClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
apps/website/src/components/ui/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="sheet-description"
|
||||
:class="cn('text-primary-warm-gray text-sm', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
apps/website/src/components/ui/sheet/SheetHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/sheet/SheetOverlay.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 fixed inset-0 z-50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
23
apps/website/src/components/ui/sheet/SheetTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogTitleProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="sheet-title"
|
||||
:class="cn('text-primary-warm-white font-semibold', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
12
apps/website/src/components/ui/sheet/SheetTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
32
apps/website/src/composables/useCurrentPath.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export function useCurrentPath() {
|
||||
const currentPath = ref('')
|
||||
|
||||
function update() {
|
||||
currentPath.value = window.location.pathname
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
document.addEventListener('astro:page-load', update)
|
||||
window.addEventListener('popstate', update)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('astro:page-load', update)
|
||||
window.removeEventListener('popstate', update)
|
||||
})
|
||||
|
||||
return currentPath
|
||||
}
|
||||
|
||||
export function isHrefActive(href: string, currentPath: string): boolean {
|
||||
if (!href || !currentPath || href.startsWith('http')) return false
|
||||
const path = href.split('#')[0].split('?')[0]
|
||||
if (!path) return false
|
||||
function norm(s: string) {
|
||||
return s.length > 1 ? s.replace(/\/$/, '') : s
|
||||
}
|
||||
return norm(path) === norm(currentPath)
|
||||
}
|
||||
@@ -62,9 +62,12 @@ export const externalLinks = {
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
} as const
|
||||
|
||||
193
apps/website/src/data/mainNavigation.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
export type NavColumnItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: 'new'
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type NavColumn = {
|
||||
header: string
|
||||
items: NavColumnItem[]
|
||||
}
|
||||
|
||||
export type NavFeatured = {
|
||||
imageSrc: string
|
||||
imageAlt?: string
|
||||
title: string
|
||||
cta: {
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
export type NavItem =
|
||||
| {
|
||||
label: string
|
||||
columns: NavColumn[]
|
||||
featured?: NavFeatured
|
||||
href?: never
|
||||
}
|
||||
| { label: string; href: string; columns?: never; featured?: never }
|
||||
|
||||
export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
const routes = getRoutes(locale)
|
||||
return [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.products', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.comfyEnterprise', locale),
|
||||
href: routes.cloudEnterprise
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colFeatures', locale),
|
||||
items: [
|
||||
// TODO: no page yet — re-enable when landing pages ship
|
||||
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
|
||||
// { label: t('nav.appMode', locale), href: '#' },
|
||||
// { label: t('nav.agentSkills', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
|
||||
imageAlt: t('nav.featuredCommunityAlt', locale),
|
||||
title: t('nav.featuredCommunityTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.watchDemo', locale),
|
||||
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.colPrograms', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyHub', locale), href: externalLinks.workflows },
|
||||
{ label: t('nav.gallery', locale), href: routes.gallery },
|
||||
{
|
||||
label: t('nav.affiliates', locale),
|
||||
href: routes.affiliates,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.learning', locale),
|
||||
href: routes.learning,
|
||||
badge: 'new'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colConnect', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.reddit', locale),
|
||||
href: externalLinks.reddit,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.x', locale),
|
||||
href: externalLinks.x,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.instagram', locale),
|
||||
href: externalLinks.instagram,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.company', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
|
||||
imageAlt: t('nav.featuredCompanyAlt', locale),
|
||||
title: t('nav.featuredCompanyTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.watchNow', locale),
|
||||
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
|
||||
href: '/customers#hero-video'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.company', locale),
|
||||
items: [
|
||||
{ label: t('nav.aboutUs', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.contact', locale), href: routes.contact }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colMore', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.customerStories', locale),
|
||||
href: routes.customers
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,14 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
},
|
||||
'cta.watchDemo': {
|
||||
en: 'Watch Demo',
|
||||
'zh-CN': '观看演示'
|
||||
},
|
||||
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
@@ -1843,10 +1851,69 @@ const translations = {
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
'nav.ctaDesktopCore': { en: 'DESKTOP', 'zh-CN': '桌面版' },
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
'nav.mobileMenuDescription': {
|
||||
en: 'Site navigation and quick links',
|
||||
'zh-CN': '网站导航和快速链接'
|
||||
},
|
||||
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
'nav.colMore': { en: 'More', 'zh-CN': '更多' },
|
||||
// Dropdown items not yet covered above
|
||||
'nav.reddit': { en: 'Reddit', 'zh-CN': 'Reddit' },
|
||||
'nav.x': { en: 'X', 'zh-CN': 'X' },
|
||||
'nav.instagram': { en: 'Instagram', 'zh-CN': 'Instagram' },
|
||||
'nav.affiliates': { en: 'Affiliates', 'zh-CN': '联盟计划' },
|
||||
'nav.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'nav.featuredCommunityAlt': {
|
||||
en: 'Sky Replacement workflow demo image',
|
||||
'zh-CN': '天空替换工作流演示图片'
|
||||
},
|
||||
'nav.featuredCommunityCtaAria': {
|
||||
en: 'Watch the Sky Replacement demo',
|
||||
'zh-CN': '观看天空替换演示'
|
||||
},
|
||||
'nav.featuredCompanyTitle': {
|
||||
en: 'Customer story: Black Math',
|
||||
'zh-CN': '客户故事:Black Math'
|
||||
},
|
||||
'nav.featuredCompanyAlt': {
|
||||
en: 'Black Math customer story image',
|
||||
'zh-CN': 'Black Math 客户故事图片'
|
||||
},
|
||||
'nav.featuredCompanyCtaAria': {
|
||||
en: 'Watch the Black Math customer story',
|
||||
'zh-CN': '观看 Black Math 客户故事'
|
||||
},
|
||||
|
||||
// SiteFooter
|
||||
'footer.tagline': {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Analytics from '@vercel/analytics/astro'
|
||||
import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -137,7 +137,7 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
<SiteNav locale={locale} github-stars={githubStars} client:load />
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../components/home/ProductCardsSection.vue'
|
||||
import HeroSection from '../components/home/HeroSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
import UseCaseSection from '../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ProductCardsSection from "../components/home/ProductCardsSection.vue";
|
||||
import HeroSection from "../components/home/HeroSection.vue";
|
||||
import SocialProofBarSection from "../components/common/SocialProofBarSection.vue";
|
||||
import ProductShowcaseSection from "../components/home/ProductShowcaseSection.vue";
|
||||
import UseCaseSection from "../components/home/UseCaseSection.vue";
|
||||
import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSection.vue";
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t('hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
|
||||
description={t("hero.subtitle", "en")}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
"comfy ui application",
|
||||
"comfyui application",
|
||||
"comfy app",
|
||||
"comfyui",
|
||||
"visual ai app",
|
||||
"node-based ai",
|
||||
"generative ai workflows",
|
||||
]}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
@import 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* PP Formula's native vertical metrics place the baseline too high, so caps
|
||||
sit in the upper half of the line box. Overriding ascent/descent re-anchors
|
||||
the baseline so caps render optically centered when line-height is tight.
|
||||
BUT, IT IS NOT WELL SUPPORTED:
|
||||
ascent-override: 92%;
|
||||
descent-override: 8%;
|
||||
line-gap-override: 0%; */
|
||||
@font-face {
|
||||
font-family: 'PP Formula';
|
||||
src: url('/fonts/PPFormula-Light.woff2') format('woff2');
|
||||
@@ -53,10 +63,12 @@
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
--color-primary-warm-white: #f0efed;
|
||||
--color-primary-warm-gray: #7e7c78;
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
@@ -100,6 +112,7 @@
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
@@ -109,6 +122,7 @@
|
||||
0% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -131,9 +145,11 @@
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.75);
|
||||
opacity: 0;
|
||||
@@ -174,6 +190,7 @@
|
||||
@utility scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -231,6 +231,22 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_interrupted` WS event (user-initiated stop). */
|
||||
executionInterrupted(jobId: string, nodeId: string): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_interrupted',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
|
||||
@@ -69,6 +69,24 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
@@ -113,6 +112,10 @@ export const TestIds = {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
card: 'asset-card'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
|
||||
@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should focus keybindings search when opening manage shortcuts', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||
).toBeFocused()
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Settings...')
|
||||
).not.toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
|
||||
|
||||
interface WidgetSnapshot {
|
||||
type: string
|
||||
value: string
|
||||
hasLayout: boolean
|
||||
}
|
||||
|
||||
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ nodeId, widgetName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
|
||||
|
||||
return {
|
||||
type: widget?.type ?? '',
|
||||
value: String(widget?.value ?? ''),
|
||||
hasLayout: widget?.last_y != null
|
||||
}
|
||||
},
|
||||
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Promoted subgraph asset widgets',
|
||||
{ tag: ['@cloud', '@canvas', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('legacy asset browser selection updates the promoted host widget value', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getHostWidgetSnapshot(comfyPage.page))
|
||||
.toMatchObject({
|
||||
type: 'asset',
|
||||
hasLayout: true
|
||||
})
|
||||
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
|
||||
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
|
||||
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
|
||||
await hostNode.centerOnNode()
|
||||
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
|
||||
await promotedWidget.click()
|
||||
|
||||
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const assetCard = modal
|
||||
.getByTestId(TestIds.assets.card)
|
||||
.filter({ hasText: SELECTED_MODEL })
|
||||
.first()
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
|
||||
)
|
||||
.toBe(SELECTED_MODEL)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
138
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -32,6 +32,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await dialog.getByTestId('pointer-zone').hover()
|
||||
await dialog.getByText('Brush Settings').hover()
|
||||
await expect(dialog.getByTestId('brush-cursor')).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
@@ -250,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -284,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
if (!observedContentType) {
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
}
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
}
|
||||
|
||||
async function setLocaleAndWaitForWorkflowReload(
|
||||
comfyPage: ComfyPage,
|
||||
locale: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(async (targetLocale) => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No active workflow while waiting for locale reload')
|
||||
}
|
||||
|
||||
const changeTracker = workflow.changeTracker.constructor as unknown as {
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
let sawLoading = false
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
if (sawLoading && !changeTracker.isLoadingGraph) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (performance.now() > timeoutAt) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
|
||||
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
|
||||
await waitForReload
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
test.describe('Locale-specific documentation', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
@@ -434,9 +382,7 @@ This is English documentation.
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
} finally {
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
} from '@e2e/fixtures/utils/painter'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
|
||||
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
|
||||
|
||||
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
})
|
||||
|
||||
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
|
||||
test.describe('Widget rendering', () => {
|
||||
test('Node enforces minimum size', async ({ comfyPage }) => {
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
@@ -28,17 +31,15 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
expect(size![1]).toBeGreaterThanOrEqual(550)
|
||||
})
|
||||
|
||||
test('Width, height, and bg_color standard widgets are hidden', async ({
|
||||
test('Does not render hidden standard widgets in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hiddenFlags = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => ['width', 'height', 'bg_color'].includes(w.name))
|
||||
.map((w) => w.options.hidden ?? false)
|
||||
})
|
||||
expect(hiddenFlags).toEqual([true, true, true])
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
|
||||
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -788,6 +789,49 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Painter legacy LiteGraph rendering',
|
||||
{ tag: ['@widget', '@canvas'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
})
|
||||
|
||||
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
|
||||
expect(painterNodes).toHaveLength(1)
|
||||
const painterNode = painterNodes[0]!
|
||||
const maskWidget = await painterNode.getWidgetByName('mask')
|
||||
const maskWidgetClientPosition = await maskWidget.getPosition()
|
||||
const widgetRowClientHeight = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
|
||||
window.app!.canvas.ds.scale
|
||||
)
|
||||
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
|
||||
for (const [
|
||||
index,
|
||||
widgetName
|
||||
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
|
||||
await test.step(`Click ${widgetName} row`, async () => {
|
||||
await comfyPage.page.mouse.click(
|
||||
maskWidgetClientPosition.x,
|
||||
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Painter — input image connection',
|
||||
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||