Compare commits
2 Commits
jaeone/mod
...
glary/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52fa622ea | ||
|
|
a065087e2f |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
pnpm test:unit -- <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
pnpm test:unit -- --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -29,5 +29,3 @@ runs:
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
name: Upsert Comment Section
|
||||
description: >
|
||||
Manage a consolidated PR comment with independently-updatable sections.
|
||||
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.
|
||||
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
|
||||
Valid section names: "e2e", "preview", "screenshot-update".
|
||||
|
||||
inputs:
|
||||
pr-number:
|
||||
description: PR number to comment on
|
||||
required: true
|
||||
section-name:
|
||||
description: 'Section identifier (e.g. "playwright", "storybook", "e2e", "preview")'
|
||||
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
|
||||
required: true
|
||||
section-content:
|
||||
description: Markdown content for this section
|
||||
required: true
|
||||
comment-marker:
|
||||
description: Top-level HTML comment marker shared by all sections in this comment
|
||||
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token with pull-requests write permission
|
||||
@@ -39,10 +38,6 @@ 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,27 +109,3 @@ 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,15 +38,16 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start — upsert playwright starting section
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
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 }}
|
||||
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"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
@@ -58,15 +59,13 @@ jobs:
|
||||
path: reports
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Handle Test Completion — deploy and generate section
|
||||
- name: Handle Test Completion
|
||||
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" ] && \
|
||||
@@ -75,22 +74,5 @@ jobs:
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"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 }}
|
||||
|
||||
52
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -45,10 +45,12 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -226,7 +228,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 section into the unified PR report comment for non-forked PRs
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
@@ -242,16 +244,17 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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 }}
|
||||
- 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"
|
||||
|
||||
# Deploy and upsert final playwright section for non-forked PRs only
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -275,34 +278,15 @@ jobs:
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
|
||||
- name: Deploy reports and generate section
|
||||
- name: Deploy reports and comment on PR
|
||||
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 }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"${{ github.head_ref }}" \
|
||||
"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,15 +38,16 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start — upsert storybook starting section
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
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 }}
|
||||
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"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion — deploy and generate section
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -65,28 +66,9 @@ 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 }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"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,14 +37,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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 }}
|
||||
- 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"
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
@@ -163,38 +164,19 @@ jobs:
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy Storybook and generate section
|
||||
- name: Deploy Storybook and comment on PR
|
||||
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 }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"${{ github.head_ref }}" \
|
||||
"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
|
||||
@@ -226,17 +208,35 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Upsert Chromatic section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
- name: Update comment with Chromatic URLs
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
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 }}
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
24
.github/workflows/detect-unreviewed-merge.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Detect Unreviewed Merge
|
||||
|
||||
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
|
||||
# tracking issues are filed in Comfy-Org/unreviewed-merges.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: detect-unreviewed-merge-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
|
||||
with:
|
||||
approval-mode: latest-per-reviewer
|
||||
secrets:
|
||||
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
|
||||
|
||||
55
.github/workflows/pr-cursor-review.yaml
vendored
@@ -1,55 +0,0 @@
|
||||
# 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 }}
|
||||
18
.github/workflows/pr-report.yaml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -140,8 +138,6 @@ jobs:
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
'<!-- PLAYWRIGHT_TEST_STATUS -->',
|
||||
'<!-- STORYBOOK_BUILD_STATUS -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
@@ -162,19 +158,11 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Read PR report
|
||||
id: report
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
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
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
section-name: ci-metrics
|
||||
section-content: ${{ steps.report.outputs.content }}
|
||||
report-file: ./pr-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
@@ -1,142 +0,0 @@
|
||||
name: Publish Desktop Bridge Types
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (e.g., 0.1.2)'
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
description: 'npm dist-tag to use'
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to checkout (commit SHA, tag, or branch)'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish_desktop_bridge_types:
|
||||
name: Publish @comfyorg/comfyui-desktop-bridge-types
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
|
||||
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
|
||||
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Determine ref to checkout
|
||||
id: resolve_ref
|
||||
env:
|
||||
REF: ${{ inputs.ref }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
if ! git check-ref-format --allow-onelevel "$REF"; then
|
||||
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.resolve_ref.outputs.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Verify package
|
||||
id: pkg
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
|
||||
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
|
||||
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
|
||||
if [ "$VERSION" != "$INPUT_VERSION" ]; then
|
||||
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if version already on npm
|
||||
id: check_npm
|
||||
env:
|
||||
NAME: ${{ steps.pkg.outputs.name }}
|
||||
VER: ${{ steps.pkg.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATUS=0
|
||||
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
|
||||
else
|
||||
if echo "$OUTPUT" | grep -q "E404"; then
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error title=Registry lookup failed::$OUTPUT" >&2
|
||||
exit "$STATUS"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Publish package
|
||||
if: steps.check_npm.outputs.exists == 'false'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DIST_TAG: ${{ inputs.dist_tag }}
|
||||
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
|
||||
working-directory: packages/comfyui-desktop-bridge-types
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
pnpm build
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
# Check if packages are already available locally
|
||||
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
echo "Installing TypeScript and Vue compiler globally..."
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
else
|
||||
echo "TypeScript and Vue compiler already available globally"
|
||||
echo "TypeScript and Vue compiler already available locally"
|
||||
fi
|
||||
|
||||
- name: Run Claude Documentation Review
|
||||
|
||||
5
.gitignore
vendored
@@ -19,7 +19,6 @@ yarn.lock
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.nx
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -90,6 +89,10 @@ storybook-static
|
||||
# MCP Servers
|
||||
.playwright-mcp/*
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
|
||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".i18nrc.cjs",
|
||||
".nx/*",
|
||||
"**/vite.config.*.timestamp*",
|
||||
"**/vitest.config.*.timestamp*",
|
||||
"components.d.ts",
|
||||
@@ -65,7 +66,6 @@
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
"no-useless-rename": "off",
|
||||
@@ -74,21 +74,17 @@
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/no-conditional-expect": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"vitest/no-standalone-expect": "off",
|
||||
"vitest/valid-title": "off",
|
||||
"vitest/require-to-throw-message": "off",
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"jest/no-standalone-expect": "off",
|
||||
"jest/valid-title": "off",
|
||||
"typescript/no-this-alias": "off",
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "off",
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"vitest/hoisted-apis-on-top": "error",
|
||||
"typescript/no-misused-spread": "error",
|
||||
"vitest/consistent-each-for": [
|
||||
"error",
|
||||
{
|
||||
|
||||
17
AGENTS.md
@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
@@ -307,20 +308,6 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
111
CODEOWNERS
@@ -1,60 +1,95 @@
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/imagecrop/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useImageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/services/load3dService.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[pnpm dev hangs]
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
@@ -23,7 +23,7 @@ flowchart TD
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
@@ -41,11 +41,11 @@ flowchart TD
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` gets stuck and won't start
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs during Vite startup
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
@@ -65,7 +65,7 @@ flowchart TD
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm clean:all && pnpm i
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
@@ -73,7 +73,7 @@ flowchart TD
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- stale local build cache
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
|
||||
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
|
||||
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
|
||||
"lint": "eslint src --cache",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
@@ -36,5 +33,88 @@
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vue-tsc": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:desktop",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite preview --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook dev -p 6007"
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook build -o dist/storybook"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/storybook"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "eslint src --cache"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"font": "inter",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"pointer": true,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH = '/affiliates/terms'
|
||||
|
||||
const SECTION_IDS = [
|
||||
'1-program-overview',
|
||||
'2-eligible-products',
|
||||
'3-commission-structure',
|
||||
'4-attribution-rules',
|
||||
'5-prohibited-activities',
|
||||
'6-content-guidelines',
|
||||
'7-termination',
|
||||
'8-program-modifications',
|
||||
'9-indemnification',
|
||||
'10-governing-law',
|
||||
'11-miscellaneous'
|
||||
] as const
|
||||
|
||||
test.describe('Affiliate Terms — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('exposes one anchor per legal section in order', async ({ page }) => {
|
||||
for (const id of SECTION_IDS) {
|
||||
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
|
||||
}
|
||||
|
||||
const orderedIds = await page.evaluate(
|
||||
(ids) => {
|
||||
const elements = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
return elements
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const relation = a.compareDocumentPosition(b)
|
||||
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
return 0
|
||||
})
|
||||
.map((el) => el.id)
|
||||
},
|
||||
[...SECTION_IDS]
|
||||
)
|
||||
expect(orderedIds).toEqual([...SECTION_IDS])
|
||||
})
|
||||
|
||||
test('renders an effective date footer', async ({ page }) => {
|
||||
await expect(page.getByText(/Effective Date:/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('skips internal-only sections (competitive analysis, open questions)', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByText(/Open questions for legal review/i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliate Terms — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('clicking a desktop TOC link scrolls to the matching section', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
|
||||
await expect(desktopToc).toBeVisible()
|
||||
|
||||
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
|
||||
await link.click()
|
||||
|
||||
const target = page.locator('[id="5-prohibited-activities"]')
|
||||
await expect(target).toBeInViewport()
|
||||
})
|
||||
|
||||
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
|
||||
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.location.hash))
|
||||
.toBe('#7-termination')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliate Terms — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('shows a collapsed accordion TOC by default', async ({ page }) => {
|
||||
const accordion = page.locator('details', {
|
||||
has: page.getByText('On this page')
|
||||
})
|
||||
await expect(accordion).toBeVisible()
|
||||
await expect(accordion).not.toHaveAttribute('open', '')
|
||||
})
|
||||
|
||||
test('expanding the accordion reveals every section link', async ({
|
||||
page
|
||||
}) => {
|
||||
const accordion = page.locator('details', {
|
||||
has: page.getByText('On this page')
|
||||
})
|
||||
await accordion.locator('summary').click()
|
||||
await expect(accordion).toHaveAttribute('open', '')
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('headings remain readable at narrow viewports without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: '1. Program Overview' })
|
||||
await expect(heading).toBeVisible()
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'heading box').not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { affiliateFaqs } from '../src/data/affiliateFaq'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH = '/affiliates'
|
||||
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
|
||||
const TERMS_PATH = '/affiliates/terms'
|
||||
const FAQ_COUNT = affiliateFaqs.length
|
||||
const FIRST_FAQ = affiliateFaqs[0]
|
||||
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
|
||||
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
|
||||
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
|
||||
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
|
||||
|
||||
test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders the closing CTA heading and apply button', async ({ page }) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const ctaHeading = ctaSection.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await expect(applyButton).toBeVisible()
|
||||
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(applyButton).toHaveAttribute('target', '_blank')
|
||||
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('CTA section links to the affiliate terms page in the same tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
|
||||
await termsLink.scrollIntoViewIfNeeded()
|
||||
await expect(termsLink).toBeVisible()
|
||||
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
|
||||
await expect(termsLink).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('emits FAQPage structured data with one entry per FAQ', async ({
|
||||
page
|
||||
}) => {
|
||||
const faqJsonLd = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const match = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('FAQPage')
|
||||
)
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await applyButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await applyButton.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const popupUrl = popup.url()
|
||||
expect(
|
||||
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
|
||||
popupUrl.includes('docs.google.com/forms')
|
||||
).toBe(true)
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: FIRST_FAQ.question.en
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading at narrow viewports', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('closing CTA stays within the viewport width', async ({ page }) => {
|
||||
const ctaHeading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const box = await ctaHeading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,6 @@ import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -15,9 +11,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
@@ -44,7 +38,7 @@ test.describe('Download page @smoke', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
@@ -58,61 +52,6 @@ test.describe('Download page @smoke', () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({ userAgent: LINUX_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
const macBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
|
||||
)
|
||||
await expect(macBtn).toBeVisible()
|
||||
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toHaveCount(2)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
|
||||
const context = await browser.newContext({ userAgent: IPHONE_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
).toBeVisible()
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
@@ -237,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
|
||||
has: page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
})
|
||||
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Local' })
|
||||
await expect(downloadLink).toBeVisible()
|
||||
await expect(downloadLink).toHaveAttribute('href', '/download')
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const TOP_LEVEL_LABELS = [
|
||||
'Products',
|
||||
'Pricing',
|
||||
'Community',
|
||||
'Company'
|
||||
] as const
|
||||
|
||||
test.describe('Desktop navigation @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
@@ -24,10 +17,14 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of TOP_LEVEL_LABELS) {
|
||||
await expect(
|
||||
desktopLinks.getByText(label, { exact: true }).first()
|
||||
).toBeVisible()
|
||||
for (const label of [
|
||||
'PRODUCTS',
|
||||
'PRICING',
|
||||
'COMMUNITY',
|
||||
'RESOURCES',
|
||||
'COMPANY'
|
||||
]) {
|
||||
await expect(desktopLinks.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
@@ -52,13 +49,13 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const productsButton = desktopLinks.getByRole('button', {
|
||||
name: 'Products'
|
||||
name: /PRODUCTS/i
|
||||
})
|
||||
await productsButton.hover()
|
||||
|
||||
const dropdown = nav.getByTestId('nav-dropdown')
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Desktop',
|
||||
'Comfy Local',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy Enterprise'
|
||||
@@ -70,22 +67,21 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
test('moving mouse away closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
|
||||
await page.locator('main').hover()
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -109,11 +105,11 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
for (const label of ['Products', 'Pricing', 'Community']) {
|
||||
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
|
||||
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
|
||||
await expect(menu.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,14 +118,24 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
await menu.getByRole('button', { name: 'Products' }).click()
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Local')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
|
||||
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('CTA buttons visible in mobile menu', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 91 KiB |
@@ -25,15 +25,12 @@
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@lucide/vue": "catalog:",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -46,8 +43,90 @@
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run"
|
||||
}
|
||||
},
|
||||
"test:coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run --coverage"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "playwright test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 140 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#211927"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.4 KiB |
3
apps/website/public/icons/check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
3
apps/website/public/icons/clients/EA.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -31,4 +31,28 @@ Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone",
|
||||
"id": "/",
|
||||
"start_url": "/"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
@@ -86,20 +86,6 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
// Stub entries that exist only to issue 301 redirects from old slugs to
|
||||
// their new canonical slugs. Keeps renames reproducible across regenerations.
|
||||
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
|
||||
{
|
||||
slug: 'grok-image',
|
||||
canonicalSlug: 'grok-imagine',
|
||||
name: 'Grok Image',
|
||||
displayName: 'Grok Image',
|
||||
directory: 'partner_nodes',
|
||||
huggingFaceUrl: '',
|
||||
workflowCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
@@ -313,8 +299,7 @@ function run(): void {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -382,7 +367,7 @@ function run(): void {
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
|
||||
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<GlassCard
|
||||
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
|
||||
>
|
||||
|
||||
@@ -74,7 +74,7 @@ useHeroAnimation({
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/about/co-founders.webm"
|
||||
poster="https://media.comfy.org/website/about/co-founders-poster.webp"
|
||||
|
||||
@@ -33,7 +33,7 @@ const values: {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto max-w-5xl text-center">
|
||||
<SectionLabel>
|
||||
{{ t('about.values.label', locale) }}
|
||||
|
||||
@@ -16,7 +16,7 @@ const investors = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
|
||||
@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
|
||||
<template #right-card>
|
||||
<img
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
|
||||
type Benefit = { id: string; description: string }
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
benefits: readonly Benefit[]
|
||||
primaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<article
|
||||
v-for="(benefit, index) in benefits"
|
||||
:key="benefit.id"
|
||||
class="flex flex-col gap-6 rounded-4xl bg-primary-comfy-ink p-6 lg:p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow font-mono text-sm font-bold tracking-wide"
|
||||
>
|
||||
{{ String(index + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<p
|
||||
class="text-base/relaxed font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ benefit.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div v-if="primaryCta" class="mt-10 flex justify-center lg:mt-12">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-20 py-4 text-base uppercase"
|
||||
variant="outline"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Asset = {
|
||||
id: string
|
||||
title: string
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
downloadLabel: string
|
||||
assets: readonly Asset[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="mx-auto max-w-6xl text-center">
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/70">
|
||||
{{ subheading }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-base font-light text-primary-comfy-canvas">
|
||||
{{ asset.title }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ downloadLabel }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Criterion = { id: string; label: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
eyebrow?: string
|
||||
criteria: readonly Criterion[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="px-6 py-10 lg:px-16 lg:py-14">
|
||||
<div
|
||||
class="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-4xl">
|
||||
{{ subheading }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
v-if="eyebrow"
|
||||
class="text-xs font-bold tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</span>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<li
|
||||
v-for="criterion in criteria"
|
||||
:key="criterion.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<CheckIcon
|
||||
class="text-primary-comfy-yellow mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-primary-comfy-canvas lg:text-base">
|
||||
{{ criterion.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
|
||||
<a
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
{{ termsLink.label }}
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
const { faqs } = defineProps<{
|
||||
heading: string
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
() => faqs.length,
|
||||
(length) => {
|
||||
if (length === expanded.length) return
|
||||
expanded.length = 0
|
||||
for (let i = 0; i < length; i += 1) expanded.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${faq.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${faq.id}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
imageSrc,
|
||||
imageAlt = '',
|
||||
imageWidth = 800,
|
||||
imageHeight = 600,
|
||||
imagePosition = 'right',
|
||||
videoSrc,
|
||||
videoPoster,
|
||||
videoTracks = [],
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
imageSrc?: string
|
||||
imageAlt?: string
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
imagePosition?: 'left' | 'right'
|
||||
videoSrc?: string
|
||||
videoPoster?: string
|
||||
videoTracks?: VideoTrack[]
|
||||
videoAutoplay?: boolean
|
||||
videoLoop?: boolean
|
||||
videoMinimal?: boolean
|
||||
videoHideControls?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="w-full lg:flex-1">
|
||||
<ProductHeroBadge
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
{{ title }}
|
||||
</template>
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-start gap-3 text-base text-primary-comfy-canvas"
|
||||
>
|
||||
<CheckIcon class="text-primary-comfy-yellow mt-1 size-5 shrink-0" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryCta"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Step = { id: string; label: string; description: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly Step[]
|
||||
}>()
|
||||
|
||||
const isRtlRow = (i: number) => Math.floor(i / 2) % 2 === 1
|
||||
const isFullSpan = (i: number, total: number) =>
|
||||
i === total - 1 && total % 2 === 1
|
||||
|
||||
function hasHorizontalConnector(i: number, total: number) {
|
||||
if (isFullSpan(i, total)) return false
|
||||
if (!isRtlRow(i) && i % 2 === 0 && i + 1 < total) return true
|
||||
if (isRtlRow(i) && i % 2 === 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function hasMobileVertical(i: number, total: number) {
|
||||
return i < total - 1
|
||||
}
|
||||
|
||||
function hasLgVertical(i: number, total: number) {
|
||||
return i % 2 === 1 && i + 1 < total
|
||||
}
|
||||
|
||||
function cardClass(i: number, total: number) {
|
||||
const fullSpan = isFullSpan(i, total)
|
||||
const rtl = isRtlRow(i)
|
||||
return cn(
|
||||
'border-primary-comfy-yellow relative rounded-3xl border-2 p-8 lg:p-10',
|
||||
fullSpan && 'lg:col-span-2',
|
||||
!fullSpan && rtl && i % 2 === 0 && 'lg:col-start-2',
|
||||
!fullSpan && rtl && i % 2 === 1 && 'lg:col-start-1'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-3xl grid-cols-1 gap-4 lg:grid-flow-dense lg:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
:class="cardClass(index, steps.length)"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow inline-block -skew-x-12 rounded-sm px-3 py-1.5 text-sm font-bold tracking-wide text-primary-comfy-ink uppercase lg:text-base"
|
||||
>
|
||||
<span class="inline-block skew-x-12">
|
||||
{{ index + 1 }}. {{ step.label }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="mt-6 text-sm/relaxed text-primary-comfy-canvas lg:text-base">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<NodeUnionIcon
|
||||
v-if="hasHorizontalConnector(index, steps.length)"
|
||||
class="text-primary-comfy-yellow absolute top-1/2 right-0 hidden size-4 translate-x-[calc(100%+2px)] -translate-y-1/2 scale-x-150 rotate-90 lg:block"
|
||||
/>
|
||||
<NodeUnionIcon
|
||||
v-if="
|
||||
hasMobileVertical(index, steps.length) ||
|
||||
hasLgVertical(index, steps.length)
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-yellow absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-[calc(100%+2px)] scale-x-150',
|
||||
!hasMobileVertical(index, steps.length) && 'hidden lg:block',
|
||||
!hasLgVertical(index, steps.length) && 'lg:hidden'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
|
||||
<div class="shrink-0 lg:min-w-48">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
>
|
||||
@@ -133,41 +133,30 @@ function scrollToDepartment(deptKey: string) {
|
||||
:href="role.jobUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</span>
|
||||
<div
|
||||
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
|
||||
>
|
||||
<span>{{ role.department }}</span>
|
||||
<span class="md:hidden">{{ role.location }}</span>
|
||||
</div>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
|
||||
>
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
|
||||
style="
|
||||
mask: url('/icons/arrow-up-right.svg') center / contain
|
||||
no-repeat;
|
||||
"
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<span class="text-primary-warm-gray text-sm">
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import WireNodeLayout from '../common/WireNodeLayout.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasons: TranslationKey[] = [
|
||||
const reasons = [
|
||||
'careers.whyJoin.reason1',
|
||||
'careers.whyJoin.reason2',
|
||||
'careers.whyJoin.reason3',
|
||||
'careers.whyJoin.reason4',
|
||||
'careers.whyJoin.reason5'
|
||||
]
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
variant,
|
||||
size,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
variant?: BrandButtonVariants['variant']
|
||||
size?: BrandButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.href ? 'a' : 'button'"
|
||||
:href="props.href"
|
||||
:target="props.target"
|
||||
:rel="resolvedRel"
|
||||
:class="
|
||||
cn(
|
||||
brandButtonVariants({ variant: props.variant, size: props.size }),
|
||||
props.class ?? ''
|
||||
)
|
||||
"
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href
|
||||
:target
|
||||
:class="cn(brandButtonVariants({ variant, size }), customClass)"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
primaryLabelKey,
|
||||
primaryHref,
|
||||
secondaryLabelKey,
|
||||
secondaryHref
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
primaryLabelKey: TranslationKey
|
||||
primaryHref?: string
|
||||
secondaryLabelKey?: TranslationKey
|
||||
secondaryHref?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<BrandButton
|
||||
:href="primaryHref"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(primaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryLabelKey"
|
||||
:href="secondaryHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(secondaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<!-- Desktop sticky nav -->
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<CategoryNav
|
||||
:categories="categories"
|
||||
@@ -135,7 +135,7 @@ function scrollToSection(id: string) {
|
||||
>
|
||||
<h2
|
||||
v-if="section.hasTitle"
|
||||
class="mb-6 text-2xl font-light text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
|
||||
>
|
||||
{{ t(key(section.id, 'title'), locale) }}
|
||||
</h2>
|
||||
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
|
||||
<!-- Paragraph -->
|
||||
<p
|
||||
v-if="block.type === 'paragraph'"
|
||||
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
|
||||
v-html="t(key(section.id, `block.${i}`), locale)"
|
||||
/>
|
||||
|
||||
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas flex items-start gap-2"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
|
||||
@@ -187,7 +187,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="flex items-start gap-3 text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
|
||||
@@ -205,7 +205,7 @@ function scrollToSection(id: string) {
|
||||
:alt="t(key(section.id, `block.${i}.alt`), locale)"
|
||||
class="w-full rounded-2xl object-cover"
|
||||
/>
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
|
||||
{{ t(key(section.id, `block.${i}.caption`), locale) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
|
||||
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
|
||||
>
|
||||
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
|
||||
</p>
|
||||
@@ -238,17 +238,17 @@ function scrollToSection(id: string) {
|
||||
<SectionLabel>
|
||||
{{ t(key(section.id, `block.${i}.label`), locale) }}
|
||||
</SectionLabel>
|
||||
<p class="mt-2 text-sm font-semibold text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
|
||||
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(key(section.id, `block.${i}.role`), locale) }}
|
||||
</p>
|
||||
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
|
||||
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
|
||||
{{ t(key(section.id, `block.${i}.name2`), locale) }}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(key(section.id, `block.${i}.role2`), locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
export type EventItem = {
|
||||
label: LocalizedText
|
||||
title: LocalizedText
|
||||
cta: LocalizedText
|
||||
href: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
descriptionKey,
|
||||
notifyLabelKey,
|
||||
notifyHref,
|
||||
events
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
notifyLabelKey: TranslationKey
|
||||
notifyHref?: string
|
||||
events: readonly EventItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
<div>
|
||||
<BrandButton
|
||||
:href="notifyHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(notifyLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta[locale] }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -41,7 +41,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section class="px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Desktop', href: '/download' },
|
||||
{ label: 'Local', href: '/local' },
|
||||
{ label: 'Cloud', href: '/cloud' },
|
||||
{ label: 'API', href: '/api' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import { externalLinks, getRoutes } from '../../../config/routes.ts'
|
||||
import GitHubStarBadge from '../GitHubStarBadge.vue'
|
||||
import HeaderMainDesktop from './HeaderMainDesktop.vue'
|
||||
import HeaderMainMobile from './HeaderMainMobile.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en', githubStars = '' } = defineProps<{
|
||||
locale?: Locale
|
||||
githubStars?: string
|
||||
}>()
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const ctaButtons = [
|
||||
{
|
||||
prefix: t('nav.ctaDesktopPrefix', locale),
|
||||
core: t('nav.ctaDesktopCore', locale),
|
||||
ariaLabel: t('nav.downloadLocal', locale),
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
prefix: t('nav.ctaCloudPrefix', locale),
|
||||
core: t('nav.ctaCloudCore', locale),
|
||||
ariaLabel: t('nav.launchCloud', locale),
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
|
||||
aria-label="Comfy home"
|
||||
>
|
||||
<img
|
||||
src="/icons/logomark.svg"
|
||||
alt="Comfy"
|
||||
class="col-span-full row-span-full h-8"
|
||||
/>
|
||||
<div
|
||||
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
|
||||
>
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<HeaderMainDesktop :locale class="hidden lg:block" />
|
||||
<HeaderMainMobile :locale class="lg:hidden" />
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<Button
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
as="a"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'default' : 'outline'"
|
||||
:aria-label="cta.ariaLabel"
|
||||
>
|
||||
<span
|
||||
><span class="hidden xl:inline-block">{{ cta.prefix }} </span
|
||||
>{{ cta.core }}</span
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,76 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenu from '@/components/ui/navigation-menu/NavigationMenu.vue'
|
||||
import NavigationMenuContent from '@/components/ui/navigation-menu/NavigationMenuContent.vue'
|
||||
import NavigationMenuItem from '@/components/ui/navigation-menu/NavigationMenuItem.vue'
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
|
||||
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
|
||||
|
||||
import {
|
||||
isHrefActive,
|
||||
useCurrentPath
|
||||
} from '../../../composables/useCurrentPath'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
const currentPath = useCurrentPath()
|
||||
|
||||
function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
if (navItem.href) return isHrefActive(navItem.href, path)
|
||||
return (
|
||||
navItem.columns?.some((column) =>
|
||||
column.items.some((item) => isHrefActive(item.href, path))
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenu data-testid="desktop-nav-links">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem
|
||||
v-for="navItem in mainNavigation"
|
||||
:key="navItem.label"
|
||||
>
|
||||
<template v-if="navItem.columns?.length">
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
<NavFeaturedCard
|
||||
v-if="navItem.featured"
|
||||
:featured="navItem.featured"
|
||||
/>
|
||||
<NavColumn
|
||||
v-for="column in navItem.columns"
|
||||
:key="column.header"
|
||||
:column="column"
|
||||
:locale="locale"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
<NavigationMenuLink
|
||||
v-else
|
||||
as-child
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
:class="navigationMenuTriggerStyle()"
|
||||
>
|
||||
<a :href="navItem.href" class="ppformula-text-center">{{
|
||||
navItem.label
|
||||
}}</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</template>
|
||||
@@ -1,167 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import { getRoutes } from '../../../config/routes.ts'
|
||||
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const routes = getRoutes(locale)
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
|
||||
const isOpen = ref(false)
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeItem = computed(() =>
|
||||
mainNavigation.find(
|
||||
(item) => item.label === activeSection.value && item.columns
|
||||
)
|
||||
)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
lockScroll()
|
||||
} else {
|
||||
unlockScroll()
|
||||
activeSection.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isOpen.value) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Sheet v-model:open="isOpen">
|
||||
<SheetTrigger
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
|
||||
>
|
||||
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
|
||||
:close-label="t('nav.close', locale)"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{{ t('nav.mobileMenuDescription', locale) }}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
|
||||
>
|
||||
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
|
||||
<span class="sr-only">{{ t('nav.home', locale) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 flex-1 overflow-hidden">
|
||||
<!-- Top-level nav -->
|
||||
<nav
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 overflow-y-auto p-1',
|
||||
activeItem ? 'opacity-0' : ''
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
:inert="activeItem ? true : undefined"
|
||||
>
|
||||
<ul class="flex flex-col gap-y-8">
|
||||
<li v-for="item in mainNavigation" :key="item.label">
|
||||
<Button
|
||||
:as="item.columns ? 'button' : 'a'"
|
||||
variant="navMuted"
|
||||
:type="item.columns ? 'button' : undefined"
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Drill-down sub-panel -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
|
||||
:class="
|
||||
activeItem
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'
|
||||
"
|
||||
:inert="activeItem ? undefined : true"
|
||||
:aria-hidden="!activeItem"
|
||||
>
|
||||
<div class="size-full overflow-y-auto py-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<template #prepend>
|
||||
<ChevronLeft />
|
||||
</template>
|
||||
{{ t('nav.back', locale) }}
|
||||
</Button>
|
||||
|
||||
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
|
||||
<div
|
||||
v-for="column in activeItem.columns"
|
||||
:key="column.header"
|
||||
class="flex flex-col gap-y-3"
|
||||
>
|
||||
<p
|
||||
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="link in column.items"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
variant="nav"
|
||||
as="a"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<NavLinkContent :item="link" :locale="locale" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
|
||||
import { isHrefActive } from '../../../composables/useCurrentPath'
|
||||
import type { NavColumn } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
|
||||
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="flex flex-col space-y-4">
|
||||
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<ul class="flex flex-col">
|
||||
<li v-for="item in column.items" :key="item.label">
|
||||
<NavigationMenuLink
|
||||
as-child
|
||||
:active="isHrefActive(item.href, currentPath)"
|
||||
class="hover:bg-transparency-white-t4"
|
||||
>
|
||||
<a
|
||||
:href="item.href"
|
||||
:target="item.external ? '_blank' : undefined"
|
||||
:rel="item.external ? 'noopener noreferrer' : undefined"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<NavLinkContent :item="item" :locale="locale" />
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import ButtonPill from '@/components/ui/button-pill/ButtonPill.vue'
|
||||
|
||||
import type { NavFeatured } from '../../../data/mainNavigation'
|
||||
|
||||
defineProps<{ featured: NavFeatured }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="shrink-0">
|
||||
<a
|
||||
:href="featured.cta.href"
|
||||
:aria-label="featured.cta.ariaLabel"
|
||||
class="group/pill-trigger relative block"
|
||||
>
|
||||
<img
|
||||
class="aspect-4/3 w-62 max-w-none rounded-xl"
|
||||
:src="featured.imageSrc"
|
||||
:alt="featured.imageAlt ?? ''"
|
||||
/>
|
||||
<p class="mt-4 font-extrabold uppercase">
|
||||
{{ featured.title }}
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<ButtonPill as="span" icon-position="left" variant="ghost">
|
||||
{{ featured.cta.label }}
|
||||
</ButtonPill>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import type { NavColumnItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
186
apps/website/src/components/common/MobileMenu.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import type { NavLink } from './NavDesktopLink.vue'
|
||||
|
||||
interface CtaLink {
|
||||
label: string
|
||||
href: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
open = false,
|
||||
navigating = false,
|
||||
links = [],
|
||||
ctaLinks = [],
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
open?: boolean
|
||||
navigating?: boolean
|
||||
links?: NavLink[]
|
||||
ctaLinks?: CtaLink[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | undefined>()
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeSectionItems = computed(
|
||||
() => links.find((l) => l.label === activeSection.value)?.items
|
||||
)
|
||||
|
||||
function onNavigate() {
|
||||
activeSection.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
const focusable = [...menu.querySelectorAll<HTMLElement>(FOCUSABLE)]
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
lockScroll()
|
||||
await nextTick()
|
||||
const menu = menuRef.value
|
||||
const firstFocusable = menu?.querySelector<HTMLElement>(FOCUSABLE)
|
||||
firstFocusable?.focus()
|
||||
menu?.addEventListener('keydown', trapFocus)
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
unlockScroll({ skipRestore: navigating })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
if (open) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="open"
|
||||
id="site-mobile-menu"
|
||||
ref="menuRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:inert="!open"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
|
||||
>
|
||||
<!-- Main list -->
|
||||
<template v-if="!activeSection">
|
||||
<div class="flex flex-1 flex-col gap-8">
|
||||
<template v-for="link in links" :key="link.label">
|
||||
<button
|
||||
v-if="link.items"
|
||||
class="text-primary-comfy-canvas text-left text-3xl font-medium"
|
||||
@click="activeSection = link.label"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="link.href"
|
||||
class="text-primary-comfy-canvas text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<BrandButton
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'solid' : 'outline'"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Drill-down sub-menu -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<button
|
||||
class="text-primary-comfy-yellow mb-6 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="bg-primary-comfy-yellow size-3 -translate-y-px rotate-180"
|
||||
style="
|
||||
mask: url('/icons/arrow-right.svg') center / contain no-repeat;
|
||||
"
|
||||
/>
|
||||
{{ t('nav.back', locale) }}
|
||||
</button>
|
||||
|
||||
<p class="text-primary-warm-gray mb-8 text-sm font-bold uppercase">
|
||||
{{ activeSection }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-8 pl-2">
|
||||
<a
|
||||
v-for="item in activeSectionItems"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="text-primary-comfy-canvas flex items-center gap-3 text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-semibold"
|
||||
>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
129
apps/website/src/components/common/NavDesktopLink.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export 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>
|
||||
@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
title: 'Comfy\nDesktop',
|
||||
title: 'Comfy\nLocal',
|
||||
description: 'Run ComfyUI on your own hardware.',
|
||||
cta: 'SEE DESKTOP FEATURES',
|
||||
cta: 'SEE LOCAL FEATURES',
|
||||
href: '#',
|
||||
bg: 'bg-primary-warm-gray'
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export const AllCards: Story = {
|
||||
template: `
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
title="Comfy\nDesktop"
|
||||
title="Comfy\nLocal"
|
||||
description="Run ComfyUI on your own hardware."
|
||||
cta="SEE DESKTOP FEATURES"
|
||||
cta="SEE LOCAL FEATURES"
|
||||
href="#"
|
||||
bg="bg-primary-warm-gray"
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
|
||||
cardDef('local', routes.download, 'bg-primary-warm-gray'),
|
||||
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
|
||||
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-secondary-cool-gray')
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
|
||||
]
|
||||
|
||||
const cards = excludeProduct
|
||||
@@ -46,9 +46,7 @@ const cards = excludeProduct
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-center px-4 text-center">
|
||||
<SectionLabel v-if="labelKey">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const {
|
||||
logoSrc = '/icons/logo.svg',
|
||||
logoAlt = 'Comfy',
|
||||
text = 'DESKTOP'
|
||||
text = 'LOCAL'
|
||||
} = defineProps<{
|
||||
logoSrc?: string
|
||||
logoAlt?: string
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
|
||||
>
|
||||
<img
|
||||
:src="logoSrc"
|
||||
@@ -37,7 +37,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
|
||||
>
|
||||
<span
|
||||
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
|
||||
|
||||
@@ -43,7 +43,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
@@ -68,10 +67,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.affiliateProgram', locale),
|
||||
href: routes.affiliates
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
261
apps/website/src/components/common/SiteNav.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<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.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: 'LOCAL',
|
||||
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="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
: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>
|
||||
@@ -3,6 +3,7 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||