Compare commits

..

9 Commits

Author SHA1 Message Date
Shibo Zhou
0ee77bdbc7 Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 15:53:47 -07:00
shibozhou
ae266f3040 Merge remote-tracking branch 'origin/main' into feat/app-mode-shared-link-telemetry
# Conflicts:
#	src/platform/telemetry/types.ts
#	src/platform/workflow/core/services/workflowService.ts
#	src/scripts/app.ts
2026-06-01 14:58:06 -07:00
shibozhou
9706bacabc fix: correct open_source normalization and mode-time-tracking scope
normalizeOpenSource compared against 'original', which is not a member
of WorkflowOpenSource (TS2367), breaking the build. Map shared_url to
shared_url, any other defined source to original, and undefined to
unknown.

Call useModeTimeTracking() in synchronous setup instead of inside
runWhenGlobalIdle so its scope-bound cleanup (tryOnScopeDispose/watch)
registers and the final duration flushes on unmount.
2026-06-01 14:40:16 -07:00
Shibo Zhou
219f8feaca Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 13:49:42 -07:00
Shibo Zhou
2eb14a1f8d Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 13:17:16 -07:00
Shibo Zhou
16877fec25 Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 12:14:03 -07:00
Shibo Zhou
8afbcdc72e feat: track active time spent per app mode (app:mode_time_spent) (#12567)
## Summary

Add an `app:mode_time_spent` telemetry event so we can measure how long
users spend in App Mode vs. graph/builder modes — today we only track
*entering* App Mode (`app:app_mode_opened`), with no exit or duration
signal.

## Changes

- **What**: New `trackModeTimeSpent` event emitting `{ mode,
duration_seconds }` across the registry and all three cloud providers
(PostHog/Mixpanel/Gtm). New `useModeTimeTracking` composable accumulates
*tab-visible* time for the current mode and flushes (emits + resets) on
mode change, tab-hide, and scope dispose. Wired into `GraphView` behind
the existing cloud gate; no-ops in OSS builds.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- **Active-time semantics**: hidden-tab time is excluded (timer pauses
on `visibilitychange` → hidden). Flushing on tab-hide also means a
closing tab is still captured, since `visibilitychange` fires reliably
before unload — no separate `unload`/`pagehide` handler needed.
- **Fragmented durations**: one event is emitted per mode-segment
(between switches/tab-hides), so analysis should **sum**
`duration_seconds` grouped by `mode` rather than treating each event as
a full session.
- **Mode granularity**: emits the actual mode string (`graph` / `app` /
`builder:*`) so app-vs-graph and builder time are both sliceable.

> Stacked on `feat/app-mode-shared-link-telemetry` (unmerged, shares
`types.ts`/`GtmTelemetryProvider.ts`). Retarget to `main` once that
merges.
2026-06-01 12:05:48 -07:00
coderabbitai[bot]
82f51e373a fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-01 18:07:49 +00:00
shibozhou
ea7bfc4919 feat: tag App Mode entry with shared-link provenance (app:app_mode_opened)
Distinguish App Mode sessions reached via a shared `?share=` link from
sessions where the user opened their own/original workflow.

The `shared_url` signal already exists at load time but was transient.
Thread the workflow's `openSource` through `afterLoadNewGraph` and emit it
as `open_source` on the `app:app_mode_opened` event (reusing the existing
WorkflowOpenSource enum). `'shared_url'` => opened via a shared link;
anything else (own file, template, unknown) => original.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:19:56 -07:00
979 changed files with 21714 additions and 77831 deletions

View File

@@ -15,11 +15,6 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -29,5 +29,3 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

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

View File

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

View File

@@ -85,16 +85,6 @@ jobs:
fi
done
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6

View File

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

View File

@@ -47,8 +47,6 @@ jobs:
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
@@ -226,7 +224,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 +240,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 +274,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)

View File

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

View File

@@ -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
});
}

View File

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

View File

@@ -5,8 +5,6 @@ on:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
types:
- completed
branches-ignore:
- main
permissions:
contents: read
@@ -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 }}

View File

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

View File

@@ -92,7 +92,9 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -65,7 +65,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 +73,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",
{

View File

@@ -78,11 +78,6 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -41,6 +42,7 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -1,60 +1,60 @@
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @comfy_frontend_devs
# 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/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
/src/components/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
# 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 @comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
# 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 @comfy_frontend_devs
/src/components/imagecrop/ @jtydhr88 @comfy_frontend_devs
/src/composables/useImageCrop.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @comfy_frontend_devs
# 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 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
# 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 @comfy_frontend_devs
/src/components/painter/ @jtydhr88 @comfy_frontend_devs
/src/composables/painter/ @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @comfy_frontend_devs
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @comfy_frontend_devs
# 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 @comfy_frontend_devs
/src/extensions/core/load3dLazy.ts @jtydhr88 @comfy_frontend_devs
/src/extensions/core/load3d/ @jtydhr88 @comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3d.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3d.test.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dDrag.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dViewer.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @comfy_frontend_devs
/src/services/load3dService.ts @jtydhr88 @comfy_frontend_devs
# Manager
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @comfy_frontend_devs
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
/src/platform/assets/mappings/ @deepme987 @comfy_frontend_devs
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -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": {}
}

View File

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

View File

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

View File

@@ -4,10 +4,6 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -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()

View File

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

View File

@@ -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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -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,7 +43,6 @@
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3062_2148)">
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
</g>
<defs>
<clipPath id="clip0_3062_2148">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 234 B

View File

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

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone",
"id": "/",
"start_url": "/"
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
const { faqs } = defineProps<{
heading: string
faqs: readonly Faq[]
}>()
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
() => faqs.length,
(length) => {
if (length === expanded.length) return
expanded.length = 0
for (let i = 0; i < length; i += 1) expanded.push(false)
}
)
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-4xl font-light text-primary-comfy-canvas 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-b border-primary-comfy-canvas/20"
>
<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-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[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>

View File

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

View File

@@ -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 }}&nbsp;</span
>{{ cta.core }}</span
>
</Button>
</div>
</nav>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('footer.blog', locale),
href: externalLinks.blog,
@@ -68,10 +67,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
},
{
label: t('footer.affiliateProgram', locale),
href: routes.affiliates
}
]
}
@@ -109,7 +104,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
<template>
<footer
ref="footerRef"
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
>
<div
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"

View 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 }}&nbsp;</span
>{{ cta.core }}
</BrandButton>
</div>
<!-- Mobile hamburger -->
<button
ref="hamburgerRef"
:class="
cn(
'flex size-10 items-center justify-center rounded-xl lg:hidden',
mobileMenuOpen
? 'border-primary-comfy-yellow border-2 bg-transparent'
: 'bg-primary-comfy-yellow'
)
"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<img
v-if="!mobileMenuOpen"
src="/icons/breadthumb.svg"
alt=""
class="h-3"
aria-hidden="true"
/>
<img
v-else
src="/icons/close.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</button>
</nav>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,152 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
>
<h2
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

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

View File

@@ -68,8 +68,7 @@ const plans: PricingPlan[] = [
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' },
{ text: 'pricing.plan.standard.feature3' }
{ text: 'pricing.plan.standard.feature2' }
]
},
{
@@ -123,11 +122,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="mt-3 text-base text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-base">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
@@ -157,7 +156,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -173,18 +172,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Summary -->
<p class="px-6 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas px-6 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
@@ -193,7 +192,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="px-6 text-sm text-primary-comfy-canvas"
class="text-primary-comfy-canvas px-6 text-sm"
>
{{ t(plan.creditsKey, locale) }}
</p>
@@ -202,7 +201,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="px-6 text-xs text-primary-comfy-canvas/80"
class="text-primary-comfy-canvas/80 px-6 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -212,10 +211,17 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
@@ -223,7 +229,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
@@ -263,7 +269,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -281,13 +287,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="mt-2 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-2 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
@@ -295,25 +301,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas/55">
<span class="text-primary-comfy-canvas/55 text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="mt-2 text-xs text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-2 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -362,7 +368,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
>
<!-- Left side -->
<div
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
>
<div>
<span
@@ -371,11 +377,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="mt-3 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-sm">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
@@ -386,7 +392,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
{{ t('pricing.footnote', locale) }}
</p>
</section>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CheckIcon from '../icons/CheckIcon.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -54,11 +53,7 @@ const features: IncludedFeature[] = [
},
{
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description'
},
{
titleKey: 'pricing.included.feature12.title',
descriptionKey: 'pricing.included.feature12.description',
descriptionKey: 'pricing.included.feature11.description',
isComingSoon: true
}
]
@@ -69,10 +64,10 @@ const features: IncludedFeature[] = [
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
>
<h2
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
>
{{ t('pricing.included.heading', locale) }}
</h2>
@@ -85,7 +80,7 @@ const features: IncludedFeature[] = [
:key="feature.titleKey"
:class="
index < features.length - 1
? 'border-b border-solid border-primary-comfy-canvas/15'
? 'border-primary-comfy-canvas/15 border-b border-solid'
: ''
"
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
@@ -99,18 +94,21 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CheckIcon
<img
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
src="/icons/check.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-sm font-medium text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->
<p
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
v-html="t(feature.descriptionKey, locale)"
/>
</div>

View File

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

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