Compare commits
50 Commits
fix/space-
...
jaeone/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7799a36d5 | ||
|
|
ed028a88be | ||
|
|
26cd975c1d | ||
|
|
83d58602b8 | ||
|
|
c2968422e6 | ||
|
|
5acd76cb6d | ||
|
|
bc885f383c | ||
|
|
fc4d44c3db | ||
|
|
7f25d28b71 | ||
|
|
67b884d0f7 | ||
|
|
05efee07ce | ||
|
|
bc212e8a19 | ||
|
|
4199ab2f01 | ||
|
|
ab6c44aabf | ||
|
|
2cdaead000 | ||
|
|
003303d8ac | ||
|
|
ca2ead3c4a | ||
|
|
1a27372e44 | ||
|
|
443e201684 | ||
|
|
cf7c68cd50 | ||
|
|
0a4021df99 | ||
|
|
eafe2af91d | ||
|
|
4c870d84ed | ||
|
|
4f60d09420 | ||
|
|
edf3e1a682 | ||
|
|
968598d9e6 | ||
|
|
305d209f6f | ||
|
|
0c23e8305f | ||
|
|
810ada61fb | ||
|
|
ec91ecd695 | ||
|
|
c408f39cee | ||
|
|
ccf2f12b25 | ||
|
|
6850d22d99 | ||
|
|
543a39a6b0 | ||
|
|
c5eb05a2e9 | ||
|
|
5a846db6cf | ||
|
|
e994e4df58 | ||
|
|
2a7340ec6c | ||
|
|
cb52a3821b | ||
|
|
7a877d0715 | ||
|
|
ac4105cca8 | ||
|
|
36b57f1e83 | ||
|
|
8c04f3261a | ||
|
|
941f220582 | ||
|
|
0df2b05790 | ||
|
|
c36da042d0 | ||
|
|
75553fc214 | ||
|
|
7438f004c1 | ||
|
|
06dda1fb38 | ||
|
|
cdde1248d4 |
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -29,3 +29,5 @@ runs:
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
name: Upsert Comment Section
|
||||
description: >
|
||||
Manage a consolidated PR comment with independently-updatable sections.
|
||||
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
|
||||
Valid section names: "e2e", "preview", "screenshot-update".
|
||||
Multiple CI workflows can share the same comment by using the same
|
||||
comment-marker and different section-names. Each workflow upserts only
|
||||
its own section, leaving other sections intact.
|
||||
|
||||
inputs:
|
||||
pr-number:
|
||||
description: PR number to comment on
|
||||
required: true
|
||||
section-name:
|
||||
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
|
||||
description: 'Section identifier (e.g. "playwright", "storybook", "e2e", "preview")'
|
||||
required: true
|
||||
section-content:
|
||||
description: Markdown content for this section
|
||||
required: true
|
||||
comment-marker:
|
||||
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
|
||||
description: Top-level HTML comment marker shared by all sections in this comment
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token with pull-requests write permission
|
||||
@@ -38,6 +39,10 @@ runs:
|
||||
const sectionContent = process.env.INPUT_SECTION_CONTENT
|
||||
const commentMarker = process.env.INPUT_COMMENT_MARKER
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(sectionName)) {
|
||||
throw new Error(`Invalid section-name: ${sectionName}`)
|
||||
}
|
||||
|
||||
const sectionStart = `<!-- section:${sectionName}:start -->`
|
||||
const sectionEnd = `<!-- section:${sectionName}:end -->`
|
||||
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
|
||||
|
||||
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -109,3 +109,27 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No PostHog references found'
|
||||
|
||||
- name: Scan dist for Customer.io telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Customer.io references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'CustomerIoTelemetryProvider' \
|
||||
-e '@customerio/cdp-analytics-browser' \
|
||||
-e 'customerio-gist-web' \
|
||||
-e '(?i)cdp\.customer\.io' \
|
||||
-e 'Comfy\.CustomerIo' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Customer.io references found in dist assets!'
|
||||
echo 'Customer.io must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
40
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -38,16 +38,15 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start
|
||||
- name: Handle Test Start — upsert playwright starting section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: playwright
|
||||
section-content: '## 🎭 Playwright: ⏳ Running...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
@@ -59,13 +58,15 @@ jobs:
|
||||
path: reports
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Handle Test Completion
|
||||
- name: Handle Test Completion — deploy and generate section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
SUMMARY_FILE: playwright-section.md
|
||||
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
@@ -74,5 +75,22 @@ jobs:
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read playwright section
|
||||
id: section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('playwright-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: playwright-section.md
|
||||
|
||||
- name: Upsert playwright section into unified report
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: playwright
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
46
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -47,6 +47,8 @@ jobs:
|
||||
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
# when using pull_request event, we have permission to comment directly
|
||||
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
# Post starting section into the unified PR report comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
@@ -240,17 +242,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting"
|
||||
- name: Upsert playwright starting section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: playwright
|
||||
section-content: '## 🎭 Playwright: ⏳ Running...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
# Deploy and upsert final playwright section for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -274,15 +275,34 @@ jobs:
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
|
||||
- name: Deploy reports and comment on PR
|
||||
- name: Deploy reports and generate section
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
SUMMARY_FILE: playwright-section.md
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read playwright section
|
||||
id: section
|
||||
if: ${{ !cancelled() && hashFiles('playwright-section.md') != '' }}
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: playwright-section.md
|
||||
|
||||
- name: Upsert playwright section into unified report
|
||||
if: ${{ !cancelled() && steps.section.outputs.content != '' }}
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: playwright
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
#### END Deployment and commenting (non-forked PRs only)
|
||||
|
||||
40
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -38,16 +38,15 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start
|
||||
- name: Handle Storybook Start — upsert storybook starting section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: storybook
|
||||
section-content: '## 🎨 Storybook: 🚧 Building...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
@@ -58,7 +57,7 @@ jobs:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
- name: Handle Storybook Completion — deploy and generate section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -66,9 +65,28 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
|
||||
SUMMARY_FILE: storybook-section.md
|
||||
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read storybook section
|
||||
id: section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('storybook-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: storybook-section.md
|
||||
|
||||
- name: Upsert storybook section into unified report
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: storybook
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
84
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -37,15 +37,14 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting"
|
||||
- name: Upsert storybook starting section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: storybook
|
||||
section-content: '## 🎨 Storybook: 🚧 Building...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
@@ -164,19 +163,38 @@ jobs:
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy Storybook and comment on PR
|
||||
- name: Deploy Storybook and generate section
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
|
||||
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
|
||||
SUMMARY_FILE: storybook-section.md
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read storybook section
|
||||
id: section
|
||||
if: hashFiles('storybook-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: storybook-section.md
|
||||
|
||||
- name: Upsert storybook section into unified report
|
||||
if: steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: storybook
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -208,35 +226,17 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Update comment with Chromatic URLs
|
||||
uses: actions/github-script@v8
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Upsert Chromatic section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
script: |
|
||||
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
|
||||
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
|
||||
|
||||
// Find the existing Storybook comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.pull_request.number }}
|
||||
});
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
|
||||
);
|
||||
|
||||
if (storybookComment && buildUrl && storybookUrl) {
|
||||
// Append Chromatic info to existing comment
|
||||
const updatedBody = storybookComment.body.replace(
|
||||
/---\n(.*)$/s,
|
||||
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
|
||||
);
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: storybookComment.id,
|
||||
body: updatedBody
|
||||
});
|
||||
}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: chromatic
|
||||
section-content: |
|
||||
### 🎨 Chromatic Visual Tests
|
||||
- 📊 [View Chromatic Build](${{ needs.chromatic-deployment.outputs.chromatic-build-url }})
|
||||
- 📚 [View Chromatic Storybook](${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }})
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
55
.github/workflows/pr-cursor-review.yaml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# Description: Team-gated multi-model Cursor review — a thin caller for the
|
||||
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
|
||||
# truth for the panel, judge, prompts, and scripts. Triggered by the
|
||||
# 'cursor-review' label.
|
||||
#
|
||||
# Access control (team-only, two layers):
|
||||
# 1. Only users with triage permission or higher can apply a label in a public
|
||||
# repo, so the public cannot trigger this.
|
||||
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
|
||||
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
|
||||
name: 'PR: Cursor Review'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
# Re-labeling cancels an in-flight run for the same PR + label.
|
||||
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cursor-review:
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
# paths (Playwright snapshots, generated manager types).
|
||||
diff_excludes: >-
|
||||
:!**/package-lock.json
|
||||
:!**/yarn.lock
|
||||
:!**/pnpm-lock.yaml
|
||||
:!**/node_modules/**
|
||||
:!**/.claude/**
|
||||
:!**/dist/**
|
||||
:!**/vendor/**
|
||||
:!**/*.generated.*
|
||||
:!**/*.min.js
|
||||
:!**/*.min.css
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
16
.github/workflows/pr-report.yaml
vendored
@@ -140,6 +140,8 @@ jobs:
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
'<!-- PLAYWRIGHT_TEST_STATUS -->',
|
||||
'<!-- STORYBOOK_BUILD_STATUS -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
@@ -160,11 +162,19 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post PR comment
|
||||
- name: Read PR report
|
||||
id: report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: ./pr-report.md
|
||||
|
||||
- name: Upsert bundle/perf/coverage section into unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./pr-report.md
|
||||
section-name: ci-metrics
|
||||
section-content: ${{ steps.report.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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
|
||||
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: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 91 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:"
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import PlayPauseButton from './PlayPauseButton.vue'
|
||||
|
||||
type VideoTrack = {
|
||||
export type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
locale?: Locale
|
||||
src?: string
|
||||
poster?: string
|
||||
tracks?: VideoTrack[]
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
|
||||
@@ -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>
|
||||
|
||||
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'
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ onUnmounted(() => {
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
:tracks="tutorial.caption"
|
||||
autoplay
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -68,7 +68,8 @@ const plans: PricingPlan[] = [
|
||||
: undefined,
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
{ text: 'pricing.plan.standard.feature2' },
|
||||
{ text: 'pricing.plan.standard.feature3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -122,11 +123,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Header -->
|
||||
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
|
||||
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p class="text-primary-comfy-canvas mt-3 text-base">
|
||||
<p class="mt-3 text-base text-primary-comfy-canvas">
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,7 +157,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -172,18 +173,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="text-primary-comfy-canvas px-6 text-sm">
|
||||
<p class="px-6 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -192,7 +193,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Credits -->
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="text-primary-comfy-canvas px-6 text-sm"
|
||||
class="px-6 text-sm text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
@@ -201,7 +202,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Estimate -->
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="text-primary-comfy-canvas/80 px-6 text-xs"
|
||||
class="px-6 text-xs text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -211,17 +212,10 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="feature in plan.features"
|
||||
@@ -229,7 +223,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -269,7 +263,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
|
||||
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -287,13 +281,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Enterprise heading -->
|
||||
<h2
|
||||
v-if="plan.isEnterprise"
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm">
|
||||
<p class="mt-2 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
@@ -301,25 +295,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<template v-if="plan.priceKey">
|
||||
<div class="mt-6 flex items-baseline gap-1">
|
||||
<span
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas/55 text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas/55">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
|
||||
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="text-primary-comfy-canvas mt-2 text-xs"
|
||||
class="mt-2 text-xs text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -368,7 +362,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
>
|
||||
<!-- Left side -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
|
||||
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -377,11 +371,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
{{ t(enterprisePlan.labelKey, locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-comfy-canvas mt-3 text-sm">
|
||||
<p class="mt-3 text-sm text-primary-comfy-canvas">
|
||||
{{ t(enterprisePlan.summaryKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,7 +386,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -54,7 +54,11 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -65,10 +69,10 @@ const features: IncludedFeature[] = [
|
||||
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
|
||||
<!-- Heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
|
||||
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.included.heading', locale) }}
|
||||
</h2>
|
||||
@@ -81,7 +85,7 @@ const features: IncludedFeature[] = [
|
||||
:key="feature.titleKey"
|
||||
:class="
|
||||
index < features.length - 1
|
||||
? 'border-primary-comfy-canvas/15 border-b border-solid'
|
||||
? 'border-b border-solid border-primary-comfy-canvas/15'
|
||||
: ''
|
||||
"
|
||||
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
|
||||
@@ -99,14 +103,14 @@ const features: IncludedFeature[] = [
|
||||
v-else
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
<p class="text-sm font-medium text-primary-comfy-canvas">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
|
||||
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
|
||||
v-html="t(feature.descriptionKey, locale)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
@@ -7,6 +8,7 @@ export interface LearningTutorial {
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
@@ -28,6 +30,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
@@ -38,7 +48,15 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -49,6 +67,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -59,6 +85,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -69,6 +103,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -79,6 +121,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
|
||||
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': {
|
||||
@@ -1244,6 +1252,10 @@ const translations = {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '可随时增加积分'
|
||||
},
|
||||
'pricing.plan.standard.feature3': {
|
||||
en: 'Run 1 workflow concurrently (via API)',
|
||||
'zh-CN': '通过 API 并发运行 1 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
|
||||
'pricing.plan.creator.summary': {
|
||||
@@ -1272,8 +1284,8 @@ const translations = {
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
en: 'Run up to 3 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 3 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
@@ -1300,8 +1312,8 @@ const translations = {
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
en: 'Run up to 5 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 5 个工作流'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
@@ -1385,9 +1397,9 @@ const translations = {
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
|
||||
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
@@ -1433,10 +1445,19 @@ const translations = {
|
||||
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA,打造专属风格。'
|
||||
},
|
||||
'pricing.included.feature11.title': {
|
||||
en: 'Run Workflows via API',
|
||||
'zh-CN': '通过 API 运行工作流'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
},
|
||||
'pricing.included.feature12.title': {
|
||||
en: 'Parallel job execution',
|
||||
'zh-CN': '并行任务执行'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
'pricing.included.feature12.description': {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
@@ -1830,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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ browser_tests/
|
||||
│ ├── components/ - Page object classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ ├── Templates.ts
|
||||
@@ -44,7 +43,7 @@ browser_tests/
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `SettingDialog`).
|
||||
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
|
||||
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
|
||||
|
||||
|
||||
436
browser_tests/assets/subgraphs/link-seed.json
Normal file
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "seed"]]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
404
browser_tests/assets/subgraphs/proxy-seed.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "seed"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
439
browser_tests/assets/subgraphs/zit-seed.json
Normal file
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [498.26665242513025, 471.46666463216144],
|
||||
"size": [510.328125, 252.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500.66667683919275, 227.8666280110677],
|
||||
"size": [507.40625, 233.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [570.266591389974, 735.4665120442708],
|
||||
"size": [378, 216],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1453.466512044271, 230.26666768391925],
|
||||
"size": [252, 138],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.866658528646, 231.46666463216144],
|
||||
"size": [252, 148],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.866689046223996, 573.4666951497395],
|
||||
"size": [378, 196],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.9444173177085, 343.52284749348956],
|
||||
"size": [225, 220],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["-1", "seed"],
|
||||
["3", "control_after_generate"]
|
||||
]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.35.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class VueNodeHelpers {
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
getNodeByTitle(title: string | RegExp): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||
})
|
||||
@@ -145,7 +145,7 @@ export class VueNodeHelpers {
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getNodeIdByTitle(title: string | RegExp): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -163,7 +163,7 @@ export class VueNodeHelpers {
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getFixtureByTitle(title: string | RegExp): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ManageGroupNode {
|
||||
footer: Locator
|
||||
header: Locator
|
||||
|
||||
constructor(
|
||||
readonly page: Page,
|
||||
readonly root: Locator
|
||||
) {
|
||||
this.footer = root.locator('footer')
|
||||
this.header = root.locator('header')
|
||||
}
|
||||
|
||||
async setLabel(name: string, label: string) {
|
||||
const active = this.root.locator('.comfy-group-manage-node-page.active')
|
||||
const input = active.getByPlaceholder(name)
|
||||
await input.fill(label)
|
||||
}
|
||||
|
||||
async save() {
|
||||
await this.footer.getByText('Save').click()
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.footer.getByText('Close').click()
|
||||
}
|
||||
|
||||
get selectedNodeTypeSelect(): Locator {
|
||||
return this.header.locator('select').first()
|
||||
}
|
||||
|
||||
async getSelectedNodeType() {
|
||||
return await this.selectedNodeTypeSelect.inputValue()
|
||||
}
|
||||
|
||||
async selectNode(name: string) {
|
||||
const list = this.root.locator('.comfy-group-manage-list-items')
|
||||
const item = list.getByText(name)
|
||||
await item.click()
|
||||
}
|
||||
|
||||
async changeTab(name: 'Inputs' | 'Widgets' | 'Outputs') {
|
||||
const header = this.root.locator('.comfy-group-manage-node header')
|
||||
const tab = header.getByText(name)
|
||||
await tab.click()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
45
browser_tests/fixtures/utils/flashDetector.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
function flagAttributeFor(testId: string) {
|
||||
const encoded = Array.from(testId, (ch) =>
|
||||
ch.charCodeAt(0).toString(16)
|
||||
).join('')
|
||||
return `data-flashed-${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the first time an element matching `[data-testid="<testId>"]` is
|
||||
* present and rendered, sampled every frame via `requestAnimationFrame` from
|
||||
* page load. Catches a dialog that mounts and unmounts within a few frames,
|
||||
* which `toBeHidden()` (final state only) cannot.
|
||||
*
|
||||
* Must be called before navigation (e.g. before `comfyPage.setup()`).
|
||||
*/
|
||||
export async function trackElementFlash(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<{ hasFlashed: () => Promise<boolean> }> {
|
||||
const flagAttribute = flagAttributeFor(testId)
|
||||
|
||||
await page.addInitScript(
|
||||
({ id, attribute }: { id: string; attribute: string }) => {
|
||||
const sample = () => {
|
||||
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
|
||||
if (el instanceof HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
document.documentElement.setAttribute(attribute, 'true')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ id: testId, attribute: flagAttribute }
|
||||
)
|
||||
|
||||
return {
|
||||
hasFlashed: async () =>
|
||||
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
@@ -525,14 +524,6 @@ export class NodeReference {
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
return new ManageGroupNode(
|
||||
this.comfyPage.page,
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
async navigateIntoSubgraph() {
|
||||
const titleHeight = await this.comfyPage.page.evaluate(() => {
|
||||
return window.LiteGraph!['NODE_TITLE_HEIGHT']
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
@@ -8,8 +7,13 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
interface ResolvedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
source: ResolvedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
@@ -20,23 +24,22 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the promoted widgets of a subgraph host node from the live graph.
|
||||
*
|
||||
* Promoted widgets are now store-backed: a host input is promoted iff it
|
||||
* carries a `widgetId`, and its interior source identity is resolved on demand
|
||||
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
|
||||
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
|
||||
* denormalization, so the helper reflects the real projection rather than a
|
||||
* deleted widget-object contract.
|
||||
*/
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -44,21 +47,49 @@ export async function getPromotedWidgets(
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
const previewExposures = node?.serialize()?.properties?.previewExposures
|
||||
if (!node?.isSubgraphNode?.())
|
||||
return { widgetSources: [], previewExposures }
|
||||
|
||||
const { subgraph } = node
|
||||
const resolveSource = (
|
||||
inputName: string
|
||||
): ResolvedWidgetSource | undefined => {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
if (inputNode.isSubgraphNode?.()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const widgetSources = (node.inputs ?? []).flatMap((input) => {
|
||||
if (!input.widgetId) return []
|
||||
const source = resolveSource(input.name)
|
||||
return source ? [source] : []
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
@@ -67,7 +98,7 @@ export async function getPromotedWidgets(
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...widgetSources.map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
135
browser_tests/tests/cloudSurveyGate.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
|
||||
* must still route a not-completed user there. Drives a raw `page` so the cloud
|
||||
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
|
||||
* devtools backend during setup).
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
async function mockSurveyNotCompleted(page: Page) {
|
||||
await page.route('**/api/settings/onboarding_survey', (r) =>
|
||||
r.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Transient auth failure: a stale workspace token makes the authenticated
|
||||
// survey check 401 — the hiccup that used to bounce working users.
|
||||
async function mockSurveyTransient401(page: Page) {
|
||||
await page.route('**/api/settings/onboarding_survey', (r) =>
|
||||
r.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User authentication required'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(APP_URL)
|
||||
|
||||
// The full app boots — CloudSurveyView is a standalone onboarding view, so
|
||||
// reaching the extension manager proves we landed on the working app and
|
||||
// the transient 401 was treated as "completed", not a bounce.
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/\/cloud\/survey/)
|
||||
})
|
||||
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(APP_URL)
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
|
||||
})
|
||||
})
|
||||
@@ -1,298 +1,50 @@
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
/**
|
||||
* Group nodes are a deprecated feature. Workflows that still contain group nodes
|
||||
* are auto-converted to subgraphs on load (with accepted lossiness).
|
||||
*/
|
||||
test.describe('Group node migration', { tag: '@node' }, () => {
|
||||
test('Auto-converts a loaded group node into a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
})
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await libraryTab.open()
|
||||
const state = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return {
|
||||
groupNodeInstances: graph.nodes.filter((n) =>
|
||||
String(n.type).startsWith('workflow>')
|
||||
).length,
|
||||
subgraphCount: graph.subgraphs.size,
|
||||
hasGroupNodesExtra: !!graph.extra?.groupNodes
|
||||
}
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
})
|
||||
expect(state.groupNodeInstances).toBe(0)
|
||||
expect(state.subgraphCount).toBe(1)
|
||||
expect(state.hasGroupNodesExtra).toBe(false)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
test(
|
||||
'Loads a legacy ("/") separator group node without error and converts it',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
// Verify the node is added to the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify the node is removed from the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('.p-tree-node-label.tree-explorer-node-label')
|
||||
.first()
|
||||
.hover()
|
||||
await expect(
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'groupnodes/group_node_identical_nodes_hidden_inputs'
|
||||
)
|
||||
|
||||
const groupNodeId = 19
|
||||
const groupNodeName = 'two_VAE_decode'
|
||||
|
||||
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeName) => {
|
||||
const {
|
||||
extra: { groupNodes }
|
||||
} = window.app!.graph!
|
||||
const { nodes } = groupNodes![nodeName]
|
||||
return nodes.reduce(
|
||||
(acc, node) => acc + (node.inputs?.length ?? 0),
|
||||
0
|
||||
)
|
||||
}, groupNodeName)
|
||||
)
|
||||
.toBe(4)
|
||||
|
||||
// Verify there are 2 visible inputs (2 have been hidden in config)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node!.inputs.length
|
||||
}, groupNodeId)
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
return !!window.LiteGraph!.registered_node_types[nodeType]
|
||||
}, GROUP_NODE_TYPE)
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
}
|
||||
|
||||
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
|
||||
.getFolder(GROUP_NODE_CATEGORY)
|
||||
.count()
|
||||
return groupNodesFolderCt === 1
|
||||
}
|
||||
|
||||
const verifyNodeLoaded = async (
|
||||
comfyPage: ComfyPage,
|
||||
expectedCount: number
|
||||
) => {
|
||||
expect(
|
||||
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
||||
).toHaveLength(expectedCount)
|
||||
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
|
||||
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
test('Copies and pastes group node within the same workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 2)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node into a newly created blank workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node across different workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Serializes group node after copy and paste across workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.clipboard.paste()
|
||||
const currentGraphState = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
|
||||
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
currentGraphState as ComfyWorkflowJSON
|
||||
)
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
103
browser_tests/tests/maskEditorLoadSave.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
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')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload 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 })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -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)
|
||||
@@ -347,55 +296,6 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should handle custom node documentation paths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// First load workflow with custom node
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
|
||||
|
||||
// Mock custom node documentation with fallback
|
||||
await comfyPage.page.route(
|
||||
'**/extensions/*/docs/*/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({ status: 404 })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Custom Node Documentation
|
||||
|
||||
This is documentation for a custom node.
|
||||
|
||||

|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes.map((n) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.nodeOps.getNodeRefById(nodeRefs[0])
|
||||
await selectNodeWithPan(comfyPage, firstNode)
|
||||
}
|
||||
|
||||
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
if (await helpButton.isVisible()) {
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('Custom Node Documentation')
|
||||
|
||||
// Check image path for custom nodes
|
||||
const image = helpPage.locator('img[alt="Custom Image"]')
|
||||
await expect(image).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
|
||||
// Mock response with potentially dangerous content
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
@@ -447,34 +347,33 @@ This is documentation for a custom node.
|
||||
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')
|
||||
@@ -483,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 }) => {
|
||||
|
||||