Compare commits
1 Commits
nathaniel/
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9719157a19 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -63,14 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
197
.github/workflows/backport-auto-merge.yaml
vendored
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,27 +134,6 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft 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 Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
8
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -73,8 +73,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shardTotal: [16]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# Run sharded tests (browsers pre-installed in container)
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
COLLECT_COVERAGE: 'true'
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# Run tests (browsers pre-installed in container)
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=${{ matrix.browser }}
|
||||
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,6 +40,3 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
44
.github/workflows/cla.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -18,47 +17,13 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -74,10 +39,9 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,7 +21,6 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -401,10 +384,6 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -437,37 +416,19 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -483,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# 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@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
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
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -23,10 +23,6 @@ on:
|
||||
required: false
|
||||
default: 'Comfy-Org/ComfyUI'
|
||||
type: string
|
||||
target_branch:
|
||||
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
check-release-week:
|
||||
@@ -53,15 +49,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
resolve-version:
|
||||
needs: check-release-week
|
||||
@@ -110,7 +103,6 @@ jobs:
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -1,730 +0,0 @@
|
||||
# Git Branching and Release Strategy
|
||||
|
||||
Status: Proposed
|
||||
Scope: ComfyUI_frontend branching, release management, and environment promotion
|
||||
Audience: frontend engineers, release rotation, QA, cloud and core release stakeholders
|
||||
|
||||
This document does three things:
|
||||
|
||||
1. Maps the current branching and release process and its measured pain points.
|
||||
2. Reviews a draft proposal to replace it with a 4-tier environment-branch model
|
||||
(testing, dev, staging, main), including an honest scorecard and a
|
||||
keep-or-reject disposition for every element of that proposal.
|
||||
3. Specifies the recommended strategy, how it addresses each pain point, and a
|
||||
phased rollout plan with risks and open questions.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The current process is not "only main". The repo runs one eternal development
|
||||
branch plus 55 frozen release branches (38 core lines, core/1.6 through
|
||||
core/1.47 with gaps; 17 cloud lines, cloud/1.31 through cloud/1.47), a
|
||||
label-driven cherry-pick backport pipeline,
|
||||
and a biweekly promotion train into ComfyUI core. The pain is real but it is
|
||||
not caused by a missing environment hierarchy. It is caused by the distance
|
||||
between main and the shipped lines: the longer a pinned release line lives,
|
||||
the more cherry-picks it needs, the harder each one gets, and the bigger and
|
||||
riskier each release batch becomes.
|
||||
|
||||
The draft 4-tier proposal is reviewed in section 4. Verdict: do not adopt as
|
||||
written (overall 4/10 for this repo), because it models a single-track web
|
||||
application while this product permanently ships three concurrent version
|
||||
tracks, and its central promise (eliminating backports) is unachievable while
|
||||
ComfyUI core pins an exact frontend package version. Eight of its underlying
|
||||
instincts are correct and are adopted into the recommendation.
|
||||
|
||||
The recommended strategy (section 6) is: one eternal branch (main), short-lived
|
||||
version branches only where a pinned version demands them (core/x.y), pipeline
|
||||
promotion of build-once artifacts for cloud environments instead of environment
|
||||
branches, hard freeze-as-code and drift limits, invariant checks instead of
|
||||
notification bots, and a machine-maintained production pointer that gives git
|
||||
visibility into what is deployed without human merge ceremony. This direction
|
||||
aligns with and extends the org's existing shipping-speed initiative rather
|
||||
than relitigating it.
|
||||
|
||||
## 2. Current topology: three concurrent tracks
|
||||
|
||||
Any branching strategy for this repo must first model what actually ships.
|
||||
There is no single production. Three tracks run concurrently, each with its own
|
||||
consumers, artifact, and patch path:
|
||||
|
||||
| Track | Consumers | Artifact | Deploys via | Patch path today |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Nightly | Community users running `--front-end-version @latest` (thousands) | GitHub release built from main | Nightly version-bump PR, tag, release | Fix merges to main, ships next nightly |
|
||||
| Cloud | cloud.comfy.org users | Static assets built per commit SHA into GCS by the cloud repo | testcloud tracks the active `cloud/x.y` tip; staging and prod promote a SHA pointer via overlays and ArgoCD | Cherry-pick to `cloud/x.y` via backport label, then staged deploy |
|
||||
| Core GA / desktop | ComfyUI stable and desktop installs | `comfyui-frontend-package` wheel on PyPI, pinned exactly in ComfyUI `requirements.txt` | Biweekly train: patch bump on `core/x.y`, PyPI publish, pin-bump PR on ComfyUI | Cherry-pick to `core/x.y`, patch release, new pin PR |
|
||||
|
||||
As of mid July 2026: main is at 1.48.x, cloud runs the 1.47 line, and the
|
||||
ComfyUI pin is still on 1.45.x. Three minors of distance between main and core
|
||||
GA is the normal operating state, not an anomaly.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FIX[Fix merges to main] --> NB[Nightly bump and tag] --> NU[Nightly users]
|
||||
FIX --> CLB[Cherry-pick onto cloud line] --> STG[Staging deploy] --> PRD[Prod deploy] --> CU[Cloud users]
|
||||
FIX --> COB[Cherry-pick onto core line] --> PYP[PyPI patch release] --> PIN[ComfyUI pin update] --> DU[Core and desktop users]
|
||||
```
|
||||
|
||||
Key mechanics worth naming because the strategy must preserve or deliberately
|
||||
replace each one:
|
||||
|
||||
- Every minor bump on main automatically freezes the previous minor into paired
|
||||
`core/x.y` and `cloud/x.y` branches and rotates the matching backport labels.
|
||||
- Backports are label-driven: `needs-backport` plus a target label triggers an
|
||||
automated cherry-pick PR; conflicts fall back to a documented manual path.
|
||||
- The cloud deploy already promotes build-once artifacts: assets are built one
|
||||
time per SHA, and staging and prod move by repointing that SHA. Rollback is a
|
||||
pointer revert with no rebuild.
|
||||
- Core GA promotion is a biweekly scheduled train that publishes to PyPI and
|
||||
drafts the ComfyUI pin-bump PR.
|
||||
- A rotating release owner (the "sheriff") drives promotions; per-PR backport
|
||||
ownership was deliberately moved to feature pods in June 2026.
|
||||
- Frontend builds destined for core GA soak in the core nightly channel
|
||||
(about two weeks for graph-touching changes) because there is no automated
|
||||
signal when a change breaks a community custom node. The soak is a
|
||||
compensating control for missing telemetry, not a property of safe code.
|
||||
|
||||
These mechanics are documented operationally in `docs/release-process.md`,
|
||||
which remains the runbook of record for day-to-day releases. This document
|
||||
governs the target strategy, and that runbook gets updated as each phase in
|
||||
section 8 lands.
|
||||
|
||||
## 3. Pain points of the current process
|
||||
|
||||
Numbered for traceability to section 7. All are observed, not hypothetical.
|
||||
|
||||
- **PP1. Drift compounds on long-lived lines.** The 1.45 line spans 69 days
|
||||
from its minor cut to its latest patch and is still the pinned line: 19
|
||||
patch releases, 61 commits landed on it after it froze (54 backport
|
||||
cherry-picks plus 7 patch-release bumps), and main-vs-stable divergence of
|
||||
428 PRs by the latest patch. Backports onto old lines increasingly do not
|
||||
apply cleanly, and some do not even work once applied because the
|
||||
surrounding code diverged (a fix was backported to a GA line where it could
|
||||
not function without a second dependency PR, and was ultimately abandoned
|
||||
as a known issue).
|
||||
- **PP2. Per-PR cherry-picks can silently miss a target.** A fix that needed
|
||||
two cloud lines landed on only one; the next release shipped the regression
|
||||
back to users. Three independent safeguards (notification bot, sweep check,
|
||||
PR comment) all failed to catch it (FE-713).
|
||||
- **PP3. Release batches grow superlinearly risky.** Missing one train window
|
||||
meant a double-minor release: 401 PRs validated in a single QA pass, the
|
||||
largest surface the team has ever had to certify, including high-risk
|
||||
subsystem rewrites.
|
||||
- **PP4. The backport tooling itself fails silently.** The notification bot was
|
||||
down for six weeks without anyone noticing; the repo-level auto-merge setting
|
||||
is off, which silently turned the backport auto-merge flag into a no-op and
|
||||
required a workaround workflow; the manual retry path of the backport
|
||||
workflow was broken (FE-1282).
|
||||
- **PP5. Freeze state lives in conversation, not tooling.** A verbally frozen
|
||||
line was bumped past by an engineer who reasonably read stalled automation as
|
||||
a missed dispatch. The resulting premature minor cut cost one to two weeks of
|
||||
recovery. The post-mortem's first action item: encode freeze state durably.
|
||||
- **PP6. Human bottlenecks and burnout.** One engineer ran the release rotation
|
||||
for roughly two months straight; urgent core releases have required chasing
|
||||
approvals late at night. Backport PRs add review friction (authors cannot
|
||||
always self-approve, approvers are asleep, label permissions vary).
|
||||
- **PP7. Latency invites bypasses.** A simple graph change takes a minimum of
|
||||
about 16 days to reach core GA (review, two-week soak, release), up to 27 if
|
||||
it just misses a window. Under deadline pressure a customer demo was shipped
|
||||
via a one-off deployment that routed around the release process entirely,
|
||||
creating an unowned production surface.
|
||||
- **PP8. Release state is hard to read.** Answering "what exactly is on cloud
|
||||
prod" requires cross-referencing a branch tip, a deploy tag, and a SHA in
|
||||
another repo's values file. Public release surfaces (GitHub releases, docs
|
||||
changelog, in-app update notice) have drifted out of sync.
|
||||
- **PP9. QA involvement is ad hoc.** Test plans are hand-built per release
|
||||
(one release needed a bespoke plan naming 84 high-risk PRs); there is no
|
||||
standing definition of entry and exit criteria per promotion gate.
|
||||
- **PP10. Everything above burns cross-team trust.** Slow stable releases are
|
||||
a recurring source of cross-team friction, and the release rotation absorbs
|
||||
that pressure personally.
|
||||
|
||||
## 4. Review of the draft 4-tier proposal
|
||||
|
||||
### 4.1 The proposed model
|
||||
|
||||
The draft proposes four long-lived branches, each auto-deploying to its own
|
||||
standing environment, promoted wholesale (no cherry-picks) by a release
|
||||
manager, with hotfixes cut from main re-entering through staging, and a
|
||||
main-to-testing back-merge closing each cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[testing branch and env] -->|release manager merge| D[dev branch and env]
|
||||
D -->|release manager merge| S[staging branch and env]
|
||||
S -->|release manager merge| M[main branch equals prod]
|
||||
M -.->|back-merge at cycle end| T
|
||||
M -->|urgent defect| H[hotfix branch] --> S
|
||||
```
|
||||
|
||||
Claimed properties: engineers merge freely into testing; dev is a guaranteed
|
||||
stable baseline; staging mirrors production for UAT; main is 1-to-1 with what
|
||||
is deployed, "eliminating the complicated and error-prone process of
|
||||
back-porting entirely."
|
||||
|
||||
This model was reviewed against the org context in sections 2 and 3, the
|
||||
industry evidence in section 5, and an adversarial defense pass (a reviewer
|
||||
whose explicit job was to defend the proposal and refute the critique; several
|
||||
initial critique points were withdrawn or narrowed as a result and are noted
|
||||
below).
|
||||
|
||||
### 4.2 What the proposal gets right
|
||||
|
||||
These instincts are correct, and each is adopted in section 6 via a cheaper
|
||||
mechanism than a branch tier:
|
||||
|
||||
- **K1. Deployed state should be inspectable in git.** Today it is not (PP8).
|
||||
Adopted as a machine-maintained prod pointer (6.5).
|
||||
- **K2. Freezes must be enforced by tooling, not by verbal agreement.** The
|
||||
premature-bump incident (PP5) proves it. Adopted as freeze-as-code (6.6).
|
||||
- **K3. Promotions need named owners and explicit gates.** Adopted via GitHub
|
||||
Environments protection rules and the existing rotation (6.4).
|
||||
- **K4. Engineers need a guaranteed-good base to branch from when trunk is
|
||||
red.** Adopted as last-green stable tags on main (6.7).
|
||||
- **K5. Fix propagation needs a hard discipline.** The draft's down-merge SLA
|
||||
becomes the inverse and industry-standard rule: upstream first, fix lands on
|
||||
main before any release line (6.3).
|
||||
- **K6. Whole-unit promotion beats per-PR picking wherever a track allows it.**
|
||||
Wholesale promotion makes the PP2 failure class (a missed per-PR pick)
|
||||
unrepresentable. Adopted for the cloud track as whole-artifact promotion,
|
||||
and bounded on the core track by a drift SLO (6.8).
|
||||
- **K7. Every promotion should auto-deploy its surface.** Directionally right
|
||||
and already sanctioned by in-flight work that removes no-value manual steps
|
||||
(FE-1176). Adopted throughout.
|
||||
- **K8. QA deserves a first-class, named slot in the release path.** The only
|
||||
document in this debate that gives QA an explicit stage. Adopted as defined
|
||||
entry/exit criteria per gate instead of a dedicated environment (6.4).
|
||||
|
||||
### 4.3 Findings
|
||||
|
||||
Ordered by severity. "Withdrawn" notes mark initial critiques that did not
|
||||
survive the adversarial defense, kept here so the review is honest about its
|
||||
own error bars.
|
||||
|
||||
**Fatal (any one of these blocks adoption as written):**
|
||||
|
||||
- **F1. The central claim is false for this product.** "Eliminates
|
||||
back-porting entirely" cannot hold while ComfyUI core pins an exact
|
||||
`comfyui-frontend-package` version and desktop users run pinned installs.
|
||||
A linear four-tier chain holds exactly one version in flight; it has no
|
||||
mechanism to patch a shipped 1.45 while 1.47 is mid-promotion and 1.48 is
|
||||
on nightly. Fixes to pinned lines remain cherry-picks plus PyPI patch
|
||||
releases, that is, backports. The proposal eliminates the word by omitting
|
||||
the surface that needs it: nowhere does it mention the pin, PyPI, desktop,
|
||||
the soak, or custom nodes. (Narrowed but confirmed under defense: wholesale
|
||||
trains do reduce drift-driven backport volume; they do not remove the
|
||||
pinned-version axis.)
|
||||
- **F2. It re-models an existing artifact pipeline as merge ceremony, and
|
||||
regresses it.** Cloud promotion is already build-once: one SHA-keyed asset
|
||||
build, promoted by pointer, rollback with no rebuild. Branch-tier CD means
|
||||
each tier builds its own merge commit, so the artifact validated on staging
|
||||
is provably not the artifact deployed from main. Worse, GitHub pull request
|
||||
merges never fast-forward, so gated promotion PRs mint a new SHA at every
|
||||
tier: "human-gated promotion" and "main is SHA-identical to prod" are
|
||||
mutually exclusive with native GitHub mechanics. One of the model's two
|
||||
core promises must break.
|
||||
- **F3. The soak has no home.** The two-week custom-node soak is the org's
|
||||
central regression control for its worst historical failure class. In a
|
||||
linear chain it either occupies the staging tier permanently (capping all
|
||||
release cadence at soak length and colliding with the hotfix path) or
|
||||
silently disappears while its automated replacement remains unstaffed.
|
||||
- **F4. It rows against the org's sanctioned direction.** The shipping-speed
|
||||
initiative targets PR-to-prod under 48 hours, backport rate under 10
|
||||
percent, and stable tags cut from main every 24 to 48 hours, backed by an
|
||||
internal PRD and in-flight Linear work (FE-1176, FE-602/BE-800, release
|
||||
gate automation). The draft adds an N-day merge freeze (at roughly 330
|
||||
merged PRs per month across all targets, about 230 of them on main, that
|
||||
is a real stall), three human promotion gates,
|
||||
and full-cycle latency for every change. DORA's trunk research lists "no
|
||||
code freezes" as a success criterion for elite delivery.
|
||||
|
||||
**Major:**
|
||||
|
||||
- **F5. The hotfix path is inoperable mid-cycle.** Hotfixes cut from main
|
||||
re-enter through staging. Whenever staging holds next-cycle content in UAT
|
||||
(most of the calendar), an urgent prod fix either waits out UAT or drags
|
||||
unreleased work to production. At the observed fix rate on live lines
|
||||
(54 backports in the 53 days after the 1.45 line froze, about one per
|
||||
day), "restart UAT on every hotfix" is a validation livelock. Notably, both parents of this model do it differently:
|
||||
GitLab Flow is strictly upstream-first, and GitFlow merges hotfixes to
|
||||
master directly. The draft inherits the weaker property of each.
|
||||
- **F6. "Guaranteed stable dev" is a smoke test, not a guarantee.** dev
|
||||
receives testing wholesale, so at promotion time dev is byte-identical to
|
||||
testing at freeze. The only stability delta is whatever a quick smoke pass
|
||||
catches, and the tier's only unique content comes from direct-to-dev
|
||||
hotfixes, which is the environment-drift anti-pattern. A second standing
|
||||
environment running identical code adds configuration-drift false positives,
|
||||
not signal. (Narrowed under defense: as a lagged checkpoint, dev does have
|
||||
value mid-cycle; but a last-green tag delivers the same checkpoint without
|
||||
a branch, an environment, or a gate.)
|
||||
- **F7. Every gate is human and every suite is undefined.** The model's
|
||||
stability claims rest entirely on "smoke tests," "all regression and
|
||||
acceptance tests," and release-manager judgment. No suite, owner, or pass
|
||||
criterion is named anywhere. Against roughly 330 PRs per month with a
|
||||
two-person QA function, undefined manual gates become either the bottleneck
|
||||
or a rubber stamp; the 401-PR pass (PP3) becomes the steady state, since
|
||||
biweekly wholesale promotion at current velocity is roughly 110 to 165 PRs
|
||||
per batch (main-line content alone runs about 110 per two weeks).
|
||||
- **F8. No rollback story, and env-branch rollback poisons future
|
||||
promotions.** The draft never mentions rollback. Reverting a bad promotion
|
||||
merge makes git treat that content as already-merged, silently dropping it
|
||||
from the next wholesale promotion until someone reverts the revert. And the
|
||||
moment a prod deploy fails after the staging-to-main merge, main is ahead of
|
||||
prod again, the exact state the model claims to abolish.
|
||||
- **F9. The four auto-deploy pipelines do not exist and cannot be driven from
|
||||
this repo.** This repo never deploys anything; it fire-and-forgets a
|
||||
dispatch to the cloud repo, which owns builds, secrets, and ArgoCD. Standing
|
||||
up per-branch CD means building four cross-repo deploy paths while the
|
||||
backend concurrently moves to per-service delivery with its own promotion
|
||||
model. The proposal is also purely additive: since it cannot serve the
|
||||
pinned-package axis (F1), all existing release machinery keeps running
|
||||
beside it, and the net delta is plus three eternal branches, plus one or two
|
||||
standing environments, plus three human gates.
|
||||
|
||||
**Spec gaps and smaller issues:**
|
||||
|
||||
- **F10. Wholesale-minus-exceptions needs a written procedure.** Excluding one
|
||||
bad PR from a promotion is revert-then-reland (and revert-the-revert later),
|
||||
a known git footgun that will otherwise be improvised for the first time
|
||||
during a release emergency. The draft's own exception clause re-authorizes
|
||||
the cherry-picking it bans. (Initial claim "this is not a git operation" was
|
||||
withdrawn: revert/reland is standard practice; the finding is that the
|
||||
procedure is unwritten and its failure mode, silently dropped features, has
|
||||
no detecting check.)
|
||||
- **F11. Freeze semantics are underspecified rather than contradictory.**
|
||||
(Initial "rules 1.c and 1.d contradict" was withdrawn.) The real gap:
|
||||
stabilization fixes must merge during the freeze, so the freeze is porous by
|
||||
design, with no tooling-enforced definition of what may enter, which is
|
||||
exactly the un-encoded-freeze failure mode of PP5.
|
||||
- **F12. Naming and contract frictions.** A tier named dev that is more stable
|
||||
than testing inverts every industry convention; repointing main's semantics
|
||||
would break the nightly channel contract, though the model does not actually
|
||||
require the top tier to be named main (withdrawn as a fatal objection: tier
|
||||
names are free variables; kept as a migration note). Rule 6.a's ancestry
|
||||
policy has no native GitHub primitive and needs a small custom status check;
|
||||
cheap, but this org's post-mortems document exactly this class of bespoke
|
||||
automation failing silently (PP4).
|
||||
|
||||
### 4.4 Scorecard
|
||||
|
||||
| Dimension | Score | Basis |
|
||||
| --- | --- | --- |
|
||||
| Internal coherence | 5/10 | F5, F8, F10, F11 are real; several initial coherence critiques were withdrawn under defense |
|
||||
| Fit to this product's topology | 3/10 | F1, F3: the pinned-package axis and the soak are unmodeled |
|
||||
| Operability on GitHub with current infra | 2/10 | F2 SHA exclusivity, F9 pipelines do not exist, additive process surface |
|
||||
| Alignment with current industry evidence | 3/10 | Section 5; env-branch promotion is a documented anti-pattern for continuously deployed surfaces, with narrow exceptions that map to pipeline approval gates |
|
||||
| Quality of underlying instincts | 8/10 | K1 through K8 are correct and adopted |
|
||||
| **Overall, as written, for this repo** | **4/10** | Fatal findings F1 through F4 |
|
||||
|
||||
For fairness: as a generic process for a single-track web application in the
|
||||
era it comes from, this model rates roughly 6.5/10; GitLab Flow's environment
|
||||
branch variant, which it closely resembles, remains documented practice. The
|
||||
low score here is about fit to this product, not about the model's pedigree.
|
||||
|
||||
### 4.5 Disposition of each proposal element
|
||||
|
||||
| Proposal element | Disposition | Why |
|
||||
| --- | --- | --- |
|
||||
| Four long-lived environment branches | Reject | F1, F2, F3, F9; environments become pipeline stages instead |
|
||||
| Wholesale promotion, no cherry-picks | Adapt | Correct for cloud as whole-artifact promotion (K6); impossible for the pinned core axis (F1) |
|
||||
| Release-manager gated merges at three tiers | Adapt | One named human gate per surface via GitHub Environments required reviewers; no new role hierarchy (PP6) |
|
||||
| N-day code freeze on the integration branch | Reject | F4; freezes are the documented anti-pattern and main never freezes today |
|
||||
| Anything in testing ships in the current cycle | Adapt | Correct WIP-limit instinct; becomes the enforceable drift SLO in 6.8 |
|
||||
| dev tier as stable baseline | Replace | Last-green stable tags on main (K4, F6) |
|
||||
| staging tier for UAT and alpha access | Keep the capability | Already exists as the staging environment plus auth-gated and per-PR preview deploys; formalize QA criteria (K8) |
|
||||
| main equals prod, 1-to-1 | Replace | Machine-maintained prod pointer ref (K1); merge-based parity is unenforceable on GitHub (F2) |
|
||||
| Hotfix via branch from main through staging | Reject | F5; hotfix path per track defined in 6.9 |
|
||||
| Back-merge main to testing at cycle end | Reject | Unnecessary once there is a single eternal branch; upstream-first makes down-merges structural (K5) |
|
||||
| GitHub-enforced containment policy (6.a) | Adopt the idea | As a 20-line ancestry status check plus release-content verification (6.8), with heartbeat alerts given PP4 |
|
||||
| Version tag on every prod deploy | Already exists | Cloud deploy tags and nightly tags; kept and unified in the prod pointer spec |
|
||||
|
||||
## 5. What the industry does
|
||||
|
||||
Genealogy first, because the draft cites a remembered decade-old post with
|
||||
detailed charts. No post titled "a better git branching strategy" from that
|
||||
era appears to exist. The remembered title and charts almost certainly belong
|
||||
to Vincent Driessen's "A successful Git branching model" (nvie.com, 2010, the
|
||||
GitFlow post). The remembered content, four auto-deploying environment tiers
|
||||
promoted by wholesale merge, matches GitLab Flow's environment-branches
|
||||
variant (2014). The hotfix-from-prod and back-merge mechanics are GitFlow's.
|
||||
The draft is therefore a hybrid of two models from 2010 to 2014, both of whose
|
||||
authors have since published significant caveats:
|
||||
|
||||
- Driessen added a note to the GitFlow post in 2020: teams shipping
|
||||
continuously delivered web software should use a much simpler flow such as
|
||||
GitHub Flow; GitFlow-style models remain reasonable for explicitly versioned
|
||||
software with multiple versions in the wild. Both halves apply here, because
|
||||
this product is both.
|
||||
- GitLab's own docs now describe the environment-branch variant alongside a
|
||||
release-branch variant with an explicit upstream-first rule (fix on main,
|
||||
cherry-pick down), the same policy Google and Red Hat practice.
|
||||
|
||||
The current consensus, by product shape:
|
||||
|
||||
- **Continuously deployed services** (GitHub, Shopify, Google, the DORA
|
||||
research corpus): one mainline, short-lived topic branches, merge queue,
|
||||
feature flags, canary or percentage rollout. Environments are deployment
|
||||
pipeline stages or GitOps folders, not branches. DORA's trunk criteria:
|
||||
three or fewer active branches, daily merges, no code freezes; elite
|
||||
performers correlate strongly with this shape. Environment-branch promotion
|
||||
is repeatedly documented as an anti-pattern (Fowler's branching patterns;
|
||||
the GitOps literature: merge-order skew, unintended config riding along,
|
||||
undocumented prod drift from direct-to-branch hotfixes, per-branch rebuilds
|
||||
violating build-once). The one conceded exception, regulated sign-off audit
|
||||
trails, is satisfied by pipeline approval gates with deployment history.
|
||||
- **Versioned or embedded software** (Chrome, Firefox, Microsoft Release
|
||||
Flow): trunk plus short-lived per-release branches, cut just in time,
|
||||
fix-only, upstream-first cherry-picks with approval, never merged back,
|
||||
retired when the version leaves support. Chrome ships a milestone every four
|
||||
weeks this way; Firefox calls the cherry-pick an uplift and gates it on
|
||||
release management approval. The heavyweight tooling both maintain is
|
||||
evidence that backports are a cost to be minimized, not a routine channel.
|
||||
- **Hybrid products like this one** split the axes: trunk plus pipeline
|
||||
promotion and flags for the continuously deployed surface; trunk plus
|
||||
short-lived release branches for the pinned, versioned surface. That is
|
||||
Microsoft Release Flow on one side and Chrome-style trains on the other,
|
||||
sharing one trunk.
|
||||
|
||||
Sources: nvie.com/posts/a-successful-git-branching-model,
|
||||
about.gitlab.com/topics/version-control/what-is-gitlab-flow,
|
||||
dora.dev/capabilities/trunk-based-development,
|
||||
martinfowler.com/articles/branching-patterns.html,
|
||||
trunkbaseddevelopment.com/branch-for-release,
|
||||
octopus.com/blog/stop-using-branches-deploying-different-gitops-environments,
|
||||
beyond.minimumcd.org/docs/reference/practices/immutable-artifacts,
|
||||
devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team,
|
||||
chromium.googlesource.com/chromium/src/+/master/docs/process/release_cycle.md,
|
||||
wiki.mozilla.org/Release_Management/Release_Process,
|
||||
github.blog/engineering/engineering-principles/deploying-branches-to-github-com,
|
||||
shopify.engineering/successfully-merging-work-1000-developers.
|
||||
|
||||
## 6. Recommended strategy
|
||||
|
||||
### 6.1 Principles
|
||||
|
||||
- **P1. One eternal branch.** main is the single integration branch and the
|
||||
nightly channel. Its semantics are a locked contract: nightly releases,
|
||||
npm type publishing, cloud build dispatch, CI triggers, and community
|
||||
tooling all key off it.
|
||||
- **P2. Build once, promote artifacts.** Every merge to main produces one
|
||||
SHA-keyed artifact. Environments receive that artifact by pointer; nothing
|
||||
is ever rebuilt per environment. The artifact that passes staging and canary
|
||||
is byte-identical to the artifact in production.
|
||||
- **P3. Upstream first, always.** Every fix lands on main first, with tests.
|
||||
Release lines receive changes only via the backport pipeline. Direct commits
|
||||
to `core/*` and `cloud/*` are blocked for humans (release automation
|
||||
excepted). This is the rule that prevents the PP2 class.
|
||||
- **P4. Branches only where a version demands one.** A long-lived branch
|
||||
exists only to serve a pinned, shipped version (`core/x.y` while ComfyUI
|
||||
pins it). No branch exists to represent an environment.
|
||||
- **P5. Invariants over notifications.** Every policy in this document that
|
||||
matters is enforced by a required check or a reconciling audit with
|
||||
heartbeat alerting, not by a bot that posts a message. PP4 is the reason.
|
||||
- **P6. Gates are automation-first, with at most one named human approval per
|
||||
surface,** held by the existing release rotation. No new role hierarchy.
|
||||
- **P7. Small batches on a fixed cadence.** The unit of release stays as small
|
||||
as the gates allow. The 401-PR batch is the documented anti-goal.
|
||||
|
||||
### 6.2 Branch roles
|
||||
|
||||
| Branch | Lifetime | Purpose | Who writes to it |
|
||||
| --- | --- | --- | --- |
|
||||
| `main` | Eternal | Integration, nightly channel, source of all builds | Engineers via peer-approved PRs |
|
||||
| `core/x.y` | Weeks; dies when the ComfyUI pin moves off x.y | Serve the pinned PyPI line | Backport automation and release bumps only |
|
||||
| `cloud/x.y` | Interim only; retired per 6.10 | Cloud release train until cloud CD lands | Backport automation only |
|
||||
| `feature/*`, `fix/*` | Days | Topic branches off main (or a last-green tag when main is red) | The author |
|
||||
| Prod pointer ref (`deployed/cloud-prod`) | Eternal, machine-written | Mirrors the verified deployed SHA after every prod sync, rollbacks included | Deploy pipeline only |
|
||||
|
||||
Target state, Phase 2 and later (until the cloud branch retirement in 6.10
|
||||
lands, the cloud leg still promotes from the interim cloud/x.y line):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ENG[Engineer merges PR to main] --> BUILD[One artifact built per SHA]
|
||||
BUILD --> NIGHT[Nightly tag and release]
|
||||
BUILD --> TC[testcloud] --> SC[staging] --> CAN[Canary percent rollout] --> PROD[Cloud prod]
|
||||
PROD --> PTR[Prod pointer ref advances]
|
||||
ENG --> GATE[Overnight release gate CI] --> TAGS[Last-green stable tag]
|
||||
TAGS -->|next minor cut| LINE[core release line, backports only] --> TRAIN[Biweekly core train: patch bump, PyPI, pin PR]
|
||||
```
|
||||
|
||||
### 6.3 The two release axes, explicitly separated
|
||||
|
||||
- **Cloud axis (continuous):** main to testcloud to staging to prod is
|
||||
artifact promotion through pipeline stages. Gates live in the pipeline: the
|
||||
overnight release-gate suite for candidate selection, a staging smoke
|
||||
checklist with named QA criteria, and canary metrics with automatic
|
||||
rollback once the canary program lands. Incomplete features land on main
|
||||
dark behind feature flags so the trunk stays releasable while work is in
|
||||
progress. No environment branches.
|
||||
- **Core axis (versioned):** `core/x.y` branches exist because an exact
|
||||
version is pinned by another product and shipped to desktops. They are cut
|
||||
automatically (as today; in the target state the cut is taken from the
|
||||
newest last-green tag rather than the raw pre-bump commit), receive fixes
|
||||
only via upstream-first backports, and are retired when the pin moves. The fix for PP1 is not a new topology;
|
||||
it is shortening how long these lines live and how far they drift (6.8).
|
||||
|
||||
### 6.4 Promotion gates
|
||||
|
||||
Implemented as GitHub Environments deployment protection rules (required
|
||||
reviewers plus deployment history), not as branch merges. This natively
|
||||
provides the audit trail that is the one legitimate case for human-gated
|
||||
promotion, and it is the same primitive the draft's release-manager gates
|
||||
actually wanted.
|
||||
|
||||
| Gate | Trigger | Automated criteria | Human |
|
||||
| --- | --- | --- | --- |
|
||||
| PR into main | Every PR | Unit, component, lint, typecheck | One peer approval |
|
||||
| Release-gate verdict | Nightly | Behavioral suite, critical-path browser tests, custom-node harness when live | None; red verdict is an incident |
|
||||
| Staging promotion | On green candidate | Artifact exists, gate verdict green | None (auto) |
|
||||
| Prod promotion | Sheriff action | Staging smoke checklist green | One: rotation owner |
|
||||
| Core GA train | Biweekly schedule | Line is green, soak or harness criteria met | One: pin PR merge in ComfyUI |
|
||||
|
||||
QA's slot (K8): the staging smoke checklist and the release regression scope
|
||||
are standing documents with named QA owners and defined entry and exit
|
||||
criteria, replacing hand-built per-release plans (PP9).
|
||||
|
||||
### 6.5 Production visibility (adopts K1)
|
||||
|
||||
A machine-maintained ref, `deployed/cloud-prod`, is written by the deploy
|
||||
pipeline only after the production sync reports healthy, and it mirrors the
|
||||
deployed SHA in both directions: a verified rollback moves the ref backward
|
||||
to the rolled-back SHA (a recorded, non-fast-forward move with the rollback
|
||||
reason logged, which the reconciler treats as healthy), so the ref never lies
|
||||
about production during an incident. A reconciling check compares the ref against the cloud repo's
|
||||
deployed SHA on every deploy and alerts on mismatch (P5). Answering "what is on prod" becomes
|
||||
`git log deployed/cloud-prod`, which is the legitimate requirement behind the
|
||||
draft's main-equals-prod rule, delivered without merge ceremony and immune to
|
||||
the F2 impossibility.
|
||||
|
||||
### 6.6 Freeze-as-code (adopts K2)
|
||||
|
||||
Freeze state is a first-class marker (a repo variable or protected file) that
|
||||
release automation checks before acting: the minor-bump dispatch refuses to
|
||||
cut past a frozen line, and backport targeting warns on frozen targets. All
|
||||
release bots carry heartbeat alerts; silence is an incident. These are the
|
||||
premature-bump post-mortem action items, promoted into the strategy.
|
||||
|
||||
### 6.7 Stable base for engineers (adopts K4)
|
||||
|
||||
The nightly release-gate verdict stamps an immutable dated tag (for example
|
||||
`stable/2026-07-18`) on the newest commit that passed the full gate and
|
||||
advances a `stable/latest-green` branch-style ref to it. A moving ref, not a
|
||||
moving tag, on purpose: git clients do not force-update moved tags by
|
||||
default, so a moving tag would silently go stale locally. When main is red,
|
||||
engineers branch from the ref or the dated tag instead of a standing dev
|
||||
branch. Red main is itself an incident
|
||||
with a named owner, which is what actually keeps the trunk usable.
|
||||
|
||||
### 6.8 Drift limits and content verification (adopts K6 and the containment idea)
|
||||
|
||||
- **Drift SLO:** the pinned release line being older than 28 days, or the pin
|
||||
sitting more than two minors behind main, pages the rotation owner and
|
||||
forces a train decision. This is the WIP limit that the draft expressed as
|
||||
"anything in testing ships this cycle," made enforceable. PP1's 69-day line
|
||||
becomes structurally impossible to reach silently. A breach also blocks the
|
||||
next minor cut until acknowledged. The SLO activates with a burn-in
|
||||
exemption for the already-breached 1.45 line, and a breach that traces to
|
||||
an unmerged ComfyUI pin PR escalates per risk R2 instead of paging the
|
||||
rotation, because that lever is not frontend-side.
|
||||
- **Convergence invariant:** a scheduled check asserts every commit on a live
|
||||
release line is an ancestor of main or arrived via the backport pipeline,
|
||||
and that every prod-deployed SHA is reachable from a release line. Roughly
|
||||
20 lines of CI; catches what three notification bots missed (PP2).
|
||||
- **Release-content verification:** before a train departs, an automated
|
||||
check confirms every PR labeled for that line actually landed on it, and
|
||||
posts the diff of intent versus content. FE-713 becomes a failing check
|
||||
instead of a user report.
|
||||
|
||||
### 6.9 Hotfix and rollback runbooks
|
||||
|
||||
Interim topology (while cloud/x.y lines exist; after the 6.10 retirement the
|
||||
cloud leg promotes last-green artifacts from main instead):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q1{Where is the defect live?} -->|Nightly only| A1[Fix on main, ships next nightly]
|
||||
Q1 -->|Cloud prod| A2[Fix on main with tests] --> A3[Backport label to the live cloud line] --> A4[Staging smoke on the patched artifact] --> A5[Prod promotion by rotation owner]
|
||||
Q1 -->|Core GA or desktop| A6[Fix on main with tests] --> A7[Backport label to the pinned core line] --> A8[Patch release to PyPI] --> A9[Pin bump PR on ComfyUI]
|
||||
Q1 -->|Deploy itself is bad, code is fine| R1[Rollback: repoint prod to previous SHA, no rebuild]
|
||||
```
|
||||
|
||||
Rules: hotfixes never restart full UAT and never bundle unreleased content;
|
||||
validation scope is the affected area plus the standing smoke checklist. The
|
||||
sanctioned path must stay hours-scale (target: under 8 working hours from
|
||||
fix merged on main to prod promotion), because the org has already
|
||||
demonstrated that a slower sanctioned path manufactures shadow deploy surfaces
|
||||
(PP7). Rollback is always a pointer move, never a git revert of a promotion;
|
||||
revert is reserved for code defects on main with an explicit reland step.
|
||||
|
||||
### 6.10 Cloud branch retirement (the end state for the cloud axis)
|
||||
|
||||
`cloud/x.y` branches exist today because cloud deploys from a frozen line.
|
||||
Once the release-gate suite plus canary auto-rollback are proven (several
|
||||
clean ramps and at least one real auto-rollback in production), cloud promotes
|
||||
last-green artifacts from main directly and the cloud branch family retires.
|
||||
The cutover checklist must cover: release-branch creation stops cutting
|
||||
`cloud/<minor>` and rotating cloud labels, the cloud deploy tag workflow
|
||||
retires, build dispatch keys on main SHAs and promotion events, and testcloud
|
||||
repoints from the cloud line tip to last-green tags. Until then, cloud/x.y
|
||||
continues exactly as today; this document changes nothing about it yet.
|
||||
|
||||
## 7. How this solves the current pain points
|
||||
|
||||
| Pain point | Mechanism in this strategy | Status |
|
||||
| --- | --- | --- |
|
||||
| PP1 drift on long lines | Drift SLO (6.8) caps line age; shorter trains; upstream-first keeps lines fix-only | Mitigated; fully solved only if ComfyUI adopts pin bumps at train cadence (risk R2) |
|
||||
| PP2 missed per-PR backports | Release-content verification plus convergence invariant (6.8); whole-artifact promotion on cloud removes per-PR picks there entirely | Solved: becomes a failing check, and unrepresentable on the cloud axis post 6.10 |
|
||||
| PP3 giant QA batches | Fixed cadence with drift SLO forces small trains (P7); release-gate CI carries per-merge burden | Mitigated; batch size cannot silently grow past the SLO, and drops under 100 if the 13.1 decision moves the core train to weekly |
|
||||
| PP4 silent tooling failure | Invariants and reconciling audits with heartbeat alerts replace notification-only bots (P5); prerequisite fixes named in 8 | Mitigated; bots can still break, but silence itself now alarms |
|
||||
| PP5 verbal freezes | Freeze-as-code guards the bump and backport paths (6.6) | Solved: the premature-bump incident becomes mechanically impossible |
|
||||
| PP6 human bottleneck | One human gate per surface on the existing rotation; merge-on-green everywhere else; backport approval rules simplified by making automation the only writer to release lines | Mitigated; the rotation remains, its late-night surface shrinks |
|
||||
| PP7 latency invites bypasses | Hours-scale sanctioned hotfix lane (6.9); canary replaces calendar soak for cloud; core latency shrinks as the harness replaces the soak | Partially solved now, fully contingent on the custom-node harness (risk R1) |
|
||||
| PP8 unreadable release state | Prod pointer ref (6.5); unified tags; one strategy document | Solved |
|
||||
| PP9 ad hoc QA | Standing gate criteria with named QA owners (6.4) | Solved by process definition |
|
||||
| PP10 cross-team trust | Publish the delivery metrics (9) on a fixed cadence so progress is legible instead of argued | Mitigated; trust follows the numbers |
|
||||
|
||||
The honest line on backports: this strategy does not eliminate them, because
|
||||
nothing can while another product pins an exact version. It makes them rare
|
||||
(drift SLO plus faster trains), safe (upstream-first plus content
|
||||
verification), and boring (automation is the only writer to release lines).
|
||||
The draft's promise was elimination; the achievable promise is a backport
|
||||
rate under 10 percent with no silent misses.
|
||||
|
||||
## 8. Rollout plan
|
||||
|
||||
Prerequisites (fix before anything else changes): repair the manual backport
|
||||
retry workflow (FE-1282), turn on the repo auto-merge setting and retire the
|
||||
cron workaround, add heartbeat alerts to every release bot, and correct the
|
||||
stale PyPI-attribution in docs/release-process.md so the runbook of record
|
||||
matches the actual workflows.
|
||||
|
||||
- **Phase 0, immediately:** adopt this document; freeze-as-code; prod pointer
|
||||
ref; drift SLO alerting; convergence invariant check; standing QA gate
|
||||
criteria drafted (their operational acceptance is P1 item 9). No branch
|
||||
topology changes at all.
|
||||
- **Phase 1, with the release gate:** overnight release-gate suite produces
|
||||
last-green tags; stable tags become the engineer base and the cloud
|
||||
candidate source; staging promotion goes automatic on green.
|
||||
- **Phase 2, with the canary:** percentage rollout with metric-gated
|
||||
auto-ramp and auto-rollback on cloud prod; hotfix lane switches to
|
||||
canary-validated promotion.
|
||||
- **Phase 3, retirement:** cloud/x.y family retires per 6.10. Core/x.y
|
||||
remains, on shorter trains, as long as ComfyUI pins exact versions; the
|
||||
calendar soak retires only when the custom-node harness is live, staffed,
|
||||
and has held the escaped-regression guardrail flat for two full cycles.
|
||||
|
||||
## 9. Goals and success metrics
|
||||
|
||||
| Goal | Metric | Target | Horizon |
|
||||
| --- | --- | --- | --- |
|
||||
| Ship fast | PR merge to cloud prod | Under 48 hours median | Phase 2 |
|
||||
| Backports rare | Backported PRs / merged PRs | Under 10 percent | Phase 2 |
|
||||
| Lines stay young | Max live release-line age | Under 28 days | Phase 1 |
|
||||
| No silent misses | Escaped regressions from missed backports | Zero | Phase 0 |
|
||||
| Batches stay small | PRs per QA-certified release | Under 100 | Phase 2, after the 13.1 cadence decision |
|
||||
| Prod is legible | Time to answer "what is on prod" | One git command | Phase 0 |
|
||||
| No shadow deploys | Unowned one-off production surfaces | Zero new | Phase 0 |
|
||||
|
||||
Leading indicators are the first three; report all seven on a fixed cadence in
|
||||
the cross-team channel (PP10).
|
||||
|
||||
## 10. Non-goals
|
||||
|
||||
- Renaming or repointing main. Its contract is locked (P1).
|
||||
- Eliminating release branches while ComfyUI pins exact frontend versions.
|
||||
That coupling is a product decision owned elsewhere; this strategy minimizes
|
||||
its cost rather than pretending it away.
|
||||
- Standing up new long-lived environments. Existing surfaces (testcloud,
|
||||
staging, per-PR previews) cover every tier the draft wanted.
|
||||
- Changing ComfyUI core's own release process, cadence, or the soak policy it
|
||||
requires; this document only defines what the frontend does on each axis.
|
||||
- Prescribing the backend or cloud repo's deployment architecture (per-service
|
||||
delivery work proceeds independently).
|
||||
|
||||
## 11. Requirements
|
||||
|
||||
**P0 (the strategy is not adopted without these):**
|
||||
|
||||
1. Freeze-as-code marker checked by version-bump and backport automation.
|
||||
Acceptance: a bump dispatch against a frozen line fails with a clear error;
|
||||
verified by test.
|
||||
2. Prod pointer ref written only by the deploy pipeline after verified sync,
|
||||
with a reconciling audit. Acceptance: mismatch alarms within one deploy
|
||||
cycle.
|
||||
3. Convergence invariant and release-content verification checks, with
|
||||
heartbeat alerting. Acceptance: a deliberately mislabeled test PR is caught
|
||||
before a train departs.
|
||||
4. Drift SLO alerting at 28 days of line age or two minors of pin divergence.
|
||||
Acceptance: alert fires in a rehearsal against a stale line.
|
||||
5. Documented hotfix and rollback runbooks (6.9) with an hours-scale SLA.
|
||||
Acceptance: one rehearsed hotfix per quarter meets the SLA.
|
||||
6. Prerequisite tooling fixes: FE-1282, auto-merge setting, bot heartbeats.
|
||||
Acceptance: a manually dispatched backport retry succeeds end to end; a
|
||||
backport PR merges via repo auto-merge with the cron workaround retired;
|
||||
killing a release bot raises its heartbeat alert.
|
||||
|
||||
**P1 (fast follows):**
|
||||
|
||||
7. Overnight release-gate suite stamping last-green tags; red verdict is an
|
||||
incident with a named owner. Acceptance: a green run stamps the dated tag
|
||||
within an hour; a red run opens an owned incident.
|
||||
8. Automatic staging promotion on green; GitHub Environments protection rules
|
||||
with deployment history as the prod gate. Acceptance: a green candidate
|
||||
reaches staging with zero human actions, and a prod promotion without the
|
||||
required reviewer is blocked in rehearsal.
|
||||
9. Standing QA gate criteria with named owners replacing per-release plans.
|
||||
Acceptance: the next release runs from the standing criteria with no
|
||||
bespoke test plan authored.
|
||||
|
||||
**P2 (future, explicitly sequenced):**
|
||||
|
||||
10. Canary with metric-gated auto-ramp and auto-rollback. Acceptance: one
|
||||
production auto-rollback triggered by canary metrics in a game day.
|
||||
11. cloud/x.y retirement per the 6.10 checklist. Acceptance: the cutover
|
||||
checklist fully executed, verified by a workflow inventory showing no
|
||||
half-dead machinery.
|
||||
12. Soak retirement gated on the custom-node harness guardrail. Acceptance:
|
||||
the harness holds the escaped-regression guardrail flat for two full
|
||||
cycles before any soak shortening.
|
||||
|
||||
## 12. Risk register
|
||||
|
||||
| ID | Risk | L | I | Level | Mitigation | Owner |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| R1 | Custom-node harness stays unstaffed, so soak cannot shrink and core latency persists | High | High | Critical | Escalate staffing as the single gating dependency of the whole program; do not shorten the soak before the harness holds the guardrail | Eng leadership |
|
||||
| R2 | ComfyUI pin adoption lags frontend trains, so drift SLO breaches are unresolvable frontend-side | High | High | Critical | Agree a pin-adoption SLA with core release stakeholders; SLO breaches tracing to an unmerged pin PR escalate to leadership instead of paging the rotation | FE eng manager with core stakeholders |
|
||||
| R3 | Canary program slips; cloud branches linger in a half-retired state | Med | High | High | Retirement is the last step with explicit cutover criteria; interim state is exactly today's process | Release pipeline owner |
|
||||
| R4 | Prod pointer or invariant checks silently break, recreating PP4 | Med | High | High | Heartbeat alerts plus reconciliation against the deploy system on every run; silence pages | DevOps |
|
||||
| R5 | Drift SLO is ignored under deadline pressure | Med | Med | Medium | SLO breach pages the rotation and blocks the next minor cut until acknowledged | Rotation owner |
|
||||
| R6 | Incremental rollout gives no visible "fixed" moment; cross-team pressure continues | Med | Med | Medium | Publish the section 9 metrics on a fixed cadence; make progress legible | FE eng manager |
|
||||
| R7 | Flag debt accumulates as flags gate incomplete work on trunk | Med | Low | Low | Review-for-deletion date on every flag; monthly cleanup | FE leads |
|
||||
|
||||
Doing nothing carries its own critical risks (drift compounds, the next
|
||||
missed backport ships another regression, rotation burnout continues); the
|
||||
status quo is not the safe option.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. Cadence of the core train once the drift SLO lands: stay biweekly or move
|
||||
to weekly? At current velocity the under-100 batch target in section 9 is
|
||||
reachable only with weekly trains, so this question blocks that one
|
||||
metric's horizon. (Rotation owner plus core release stakeholders; blocking
|
||||
for the batch-size target, non-blocking for everything else.)
|
||||
2. Should the prod pointer cover core GA and desktop as refs too
|
||||
(`deployed/core-ga`), or is PyPI plus the pin authoritative enough?
|
||||
(Frontend leads; non-blocking.)
|
||||
3. Who owns the standing QA gate criteria documents long-term as QA staffing
|
||||
changes? (QA plus FE eng manager; blocking for Phase 0 sign-off.)
|
||||
4. Does the org-admin limitation on separate go-live approvers (surfaced
|
||||
during FE-1176) need resolution before GitHub Environments become the prod
|
||||
gate? (DevOps plus org admins; blocking for P1 item 8.)
|
||||
|
||||
## 14. References
|
||||
|
||||
Internal: `docs/release-process.md` (the operational runbook of record,
|
||||
updated as phases land); the shipping-speed initiative and its release-gate
|
||||
and canary design docs; the release rotation runbook; the
|
||||
premature-version-bump post-mortem; FE-713, FE-1176, FE-1282, FE-602/BE-800;
|
||||
the draft 4-tier proposal this document reviews.
|
||||
|
||||
External: see section 5 source list.
|
||||
@@ -10,13 +10,11 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
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 ({
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Brand portal @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/brand')
|
||||
})
|
||||
|
||||
test('renders each brand guideline section', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'One mark, many dimensions.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Every color earns its place.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Precise, never cute.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Trademark guidelines.' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows all four marks', async ({ page }) => {
|
||||
const logos = page.locator('#logos')
|
||||
for (const name of [
|
||||
'Core Logo',
|
||||
'Logomark',
|
||||
'Icon',
|
||||
'Amplified Logomark'
|
||||
]) {
|
||||
await expect(logos.getByText(name, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the hero ctas open the gated guidelines and the logo bundle', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'View brand guidelines' })
|
||||
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Download logos' })
|
||||
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Careers - Comfy')
|
||||
await expect(page).toHaveTitle('Careers — Comfy')
|
||||
})
|
||||
|
||||
test('Roles section heading is visible', async ({ page }) => {
|
||||
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
await expect(page).toHaveTitle('招聘 - Comfy')
|
||||
await expect(page).toHaveTitle('招聘 — Comfy')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '职位', level: 2 })
|
||||
).toBeVisible()
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud - supported by default'
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
|
||||
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop - Run AI on Your Hardware'
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
|
||||
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning - Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 - Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
})
|
||||
|
||||
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
|
||||
page
|
||||
}) => {
|
||||
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
|
||||
await expect(installLinks).toHaveCount(2)
|
||||
for (const link of await installLinks.all()) {
|
||||
await expect(link).toHaveAttribute('href', '#setup')
|
||||
}
|
||||
})
|
||||
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
|
||||
// Claude Code is the default tab and carries the CLI command
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Code' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(activePanel).toContainText(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Cursor' }).click()
|
||||
await expect(activePanel).toContainText('X-API-Key')
|
||||
await expect(
|
||||
activePanel.getByRole('link', { name: 'platform.comfy.org' })
|
||||
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
})
|
||||
|
||||
test('capabilities section shows all six tool cards', async ({ page }) => {
|
||||
for (const title of [
|
||||
'Generate anything',
|
||||
'Search the ecosystem',
|
||||
'Run real workflows',
|
||||
'Direct any model',
|
||||
'Generate in batches',
|
||||
'Ship it as an app'
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: title, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQ lists nine questions and autolinks the server URL', async ({
|
||||
page
|
||||
}) => {
|
||||
const triggers = page.locator('[id^="faq-trigger-"]')
|
||||
await triggers.first().scrollIntoViewIfNeeded()
|
||||
await expect(triggers).toHaveCount(9)
|
||||
|
||||
await page.getByRole('button', { name: "What's the server URL?" }).click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
|
||||
).toHaveAttribute('href', MCP_ENDPOINT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('MCP page zh-CN @smoke', () => {
|
||||
test('setup section renders localized options', async ({ page }) => {
|
||||
await page.goto('/zh-CN/mcp')
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(setup.getByText('方式一')).toBeVisible()
|
||||
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
desktopLinks
|
||||
.getByRole('button', { name: label })
|
||||
.getByText('NEW', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('CTA buttons are visible', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
menu.getByRole('button', { name: label }).getByText('NEW', {
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clicking section with subitems drills down and back works', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful - Comfy')
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed - Comfy')
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 - Comfy')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 - Comfy')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
@@ -17,8 +17,7 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
|
||||
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -1,129 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
type AnswerPart = { type: 'text' | 'link'; value: string }
|
||||
|
||||
function parseAnswer(answer: string): AnswerPart[] {
|
||||
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
|
||||
const parts: AnswerPart[] = []
|
||||
let lastIndex = 0
|
||||
for (const match of answer.matchAll(urlPattern)) {
|
||||
const start = match.index ?? 0
|
||||
const url = match[0].replace(/[.,;:]+$/, '')
|
||||
if (start > lastIndex) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
|
||||
}
|
||||
parts.push({ type: 'link', value: url })
|
||||
lastIndex = start + url.length
|
||||
}
|
||||
if (lastIndex < answer.length) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
const parsedFaqs = computed(() =>
|
||||
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
|
||||
)
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
@@ -65,7 +40,7 @@ function toggle(index: number) {
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in parsedFaqs"
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
@@ -108,23 +83,8 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p
|
||||
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in faq.answerParts"
|
||||
:key="partIndex"
|
||||
>
|
||||
<a
|
||||
v-if="part.type === 'link'"
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ part.value }}</a
|
||||
>
|
||||
<template v-else>{{ part.value }}</template>
|
||||
</template>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
@@ -20,7 +20,6 @@ type RowMedia =
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
@@ -59,7 +58,7 @@ const {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
@@ -73,11 +72,10 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
@@ -88,12 +86,7 @@ const {
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full rounded-4xl',
|
||||
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
|
||||
)
|
||||
"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
@@ -106,13 +99,7 @@ const {
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
:fit="row.media.fit"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full',
|
||||
row.media.fit === 'contain' && 'bg-transparent'
|
||||
)
|
||||
"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="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
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="ppformula-text-center">{{ navItem.label }}</span>
|
||||
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
|
||||
</span>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 NewBadge from './NewBadge.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'
|
||||
@@ -97,8 +96,7 @@ onUnmounted(() => {
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 NewBadge from './NewBadge.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
|
||||
<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"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
|
||||
import type { BadgeVariants } from '@/components/ui/badge'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :size="size" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -30,14 +28,11 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -85,9 +84,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.brand', locale), href: routes.brand },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
]
|
||||
}
|
||||
@@ -176,7 +173,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="pointer-events-none size-52 opacity-80 lg:mt-28"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -31,9 +30,7 @@ const {
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
class: className
|
||||
hideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
@@ -43,8 +40,6 @@ const {
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -194,12 +189,7 @@ function toggleFullscreen() {
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
:class="
|
||||
cn(
|
||||
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
|
||||
className
|
||||
)
|
||||
"
|
||||
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
|
||||
@pointermove="showControls"
|
||||
@pointerdown="showControls"
|
||||
@focusin="showControls"
|
||||
@@ -207,9 +197,7 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
class="size-full object-cover"
|
||||
:src
|
||||
:poster
|
||||
:preload="autoplay ? 'auto' : 'metadata'"
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET_PX,
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -16,7 +15,8 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { hasKey, translationKeys } from '../../i18n/translations'
|
||||
|
||||
const PREFIX = 'enterprise-msa'
|
||||
|
||||
function deriveMsaSectionIds(): 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('enterprise MSA i18n', () => {
|
||||
it('every derived section has a title and at least one block', () => {
|
||||
const sectionIds = deriveMsaSectionIds()
|
||||
expect(sectionIds.length).toBeGreaterThan(0)
|
||||
for (const id of sectionIds) {
|
||||
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the page-chrome keys the .astro file references', () => {
|
||||
for (const suffix of [
|
||||
'effective-date',
|
||||
'page.title',
|
||||
'page.description',
|
||||
'page.heading',
|
||||
'page.tocLabel',
|
||||
'page.effectiveDateLabel',
|
||||
'page.parties'
|
||||
]) {
|
||||
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
|
||||
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
|
||||
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -29,7 +27,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -39,11 +37,10 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<a
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -54,27 +51,23 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -15,10 +14,11 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
}
|
||||
]
|
||||
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,18 +100,23 @@ const cardClass =
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="cardClass"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -126,6 +134,11 @@ const cardClass =
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -155,14 +168,10 @@ const cardClass =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,14 @@ export const badgeVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]',
|
||||
xxs: 'px-1.5 py-px text-[8px]'
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -20,8 +20,6 @@ export const buttonVariants = cva(
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
inline:
|
||||
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -14,8 +11,6 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -25,32 +20,15 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: localizeHref(link.href, locale),
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,12 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp',
|
||||
brand: '/brand'
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -37,37 +35,19 @@ type Routes = typeof baseRoutes
|
||||
// block in src/i18n/translations.ts for the reasoning.
|
||||
//
|
||||
// termsOfService: legal-reviewed English-only document, same reasoning.
|
||||
//
|
||||
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
|
||||
// Customer Agreement template), same reasoning. See the comment header
|
||||
// in src/pages/enterprise-msa.astro.
|
||||
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'affiliates',
|
||||
'affiliateTerms',
|
||||
'termsOfService',
|
||||
'enterpriseMsa'
|
||||
'termsOfService'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -80,23 +60,18 @@ export const externalLinks = {
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpEndpoint: 'https://cloud.comfy.org/mcp',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
import { BRAND_ASSETS_ZIP } from './brandAssets'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Shared brand download URLs served from the media bucket, used by both the
|
||||
// affiliate page and the brand portal.
|
||||
export const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
|
||||
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
|
||||
export const BRAND_GUIDELINES_PDF =
|
||||
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'
|
||||
@@ -1,91 +0,0 @@
|
||||
interface BrandColor {
|
||||
name: string
|
||||
hex: string
|
||||
rgb: string
|
||||
hsl: string
|
||||
cmyk: string
|
||||
swatchClass: string
|
||||
textClass: string
|
||||
wide?: boolean
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
export const brandColors: readonly BrandColor[] = [
|
||||
{
|
||||
name: 'Comfy Yellow',
|
||||
hex: '#F2FF59',
|
||||
rgb: '242, 255, 89',
|
||||
hsl: '65, 100, 67',
|
||||
cmyk: '5, 0, 65, 0',
|
||||
swatchClass: 'bg-primary-comfy-yellow',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Ink',
|
||||
hex: '#211927',
|
||||
rgb: '33, 25, 39',
|
||||
hsl: '274, 22, 13',
|
||||
cmyk: '15, 36, 0, 85',
|
||||
swatchClass: 'bg-primary-comfy-ink',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Canvas',
|
||||
hex: '#C2BFB9',
|
||||
rgb: '194, 191, 185',
|
||||
hsl: '40, 7, 74',
|
||||
cmyk: '0, 2, 5, 24',
|
||||
swatchClass: 'bg-primary-comfy-canvas',
|
||||
textClass: 'text-primary-comfy-ink'
|
||||
},
|
||||
{
|
||||
name: 'Comfy Plum',
|
||||
hex: '#49378B',
|
||||
rgb: '73, 55, 139',
|
||||
hsl: '253, 43, 38',
|
||||
cmyk: '47, 60, 0, 45',
|
||||
swatchClass: 'bg-primary-comfy-plum',
|
||||
textClass: 'text-primary-comfy-canvas'
|
||||
},
|
||||
{
|
||||
name: 'Warm White',
|
||||
hex: '#F0EFED',
|
||||
rgb: '240, 239, 237',
|
||||
hsl: '40, 9, 94',
|
||||
cmyk: '0, 0, 1, 6',
|
||||
swatchClass: 'bg-primary-warm-white',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Warm Gray',
|
||||
hex: '#7E7C78',
|
||||
rgb: '126, 124, 120',
|
||||
hsl: '40, 2, 48',
|
||||
cmyk: '0, 2, 5, 51',
|
||||
swatchClass: 'bg-primary-warm-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Cool Gray',
|
||||
hex: '#3C3C3C',
|
||||
rgb: '60, 60, 60',
|
||||
hsl: '0, 0, 24',
|
||||
cmyk: '0, 0, 0, 76',
|
||||
swatchClass: 'bg-secondary-cool-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Mauve',
|
||||
hex: '#4D3762',
|
||||
rgb: '77, 55, 98',
|
||||
hsl: '271, 28, 30',
|
||||
cmyk: '21, 44, 0, 62',
|
||||
swatchClass: 'bg-secondary-mauve',
|
||||
textClass: 'text-primary-warm-white'
|
||||
}
|
||||
] as const
|
||||
@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||
@@ -30,31 +30,23 @@ export type NavItem =
|
||||
label: string
|
||||
columns: NavColumn[]
|
||||
featured?: NavFeatured
|
||||
badge?: 'new'
|
||||
href?: never
|
||||
}
|
||||
| {
|
||||
label: string
|
||||
href: string
|
||||
badge?: 'new'
|
||||
columns?: never
|
||||
featured?: never
|
||||
}
|
||||
| { label: string; href: string; columns?: never; featured?: never }
|
||||
|
||||
export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
const routes = getRoutes(locale)
|
||||
return [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
badge: 'new',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: routes.mcp
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@@ -90,7 +82,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
@@ -103,7 +94,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
badge: 'new',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
|
||||
imageAlt: t('nav.featuredCommunityAlt', locale),
|
||||
|
||||
@@ -5,19 +5,8 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import { isHrefActive } from '../composables/useCurrentPath'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -25,10 +14,6 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -37,54 +22,43 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
// A promo never advertises the page you are already on, so the banner is
|
||||
// suppressed when its CTA points at the current path.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible =
|
||||
evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -126,7 +100,10 @@ const structuredData = noindex
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -146,25 +123,7 @@ const structuredData = noindex
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -178,16 +137,8 @@ const structuredData = noindex
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main>
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="404 - Page Not Found - Comfy"
|
||||
title="404 — Page Not Found — Comfy"
|
||||
noindex>
|
||||
<div
|
||||
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">
|
||||
|
||||
@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About Us - Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', locale)}
|
||||
description={t('brand.page.description', locale)}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection />
|
||||
<BrandLogosSection />
|
||||
<BrandColorSection client:visible />
|
||||
<BrandVoiceSection />
|
||||
<BrandTrademarkSection />
|
||||
<BrandQuestionsSection />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers - Comfy"
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||