Compare commits

..

10 Commits

Author SHA1 Message Date
Marwan Ahmed
417632504c feat(website): swap hero output to real renders when available
The output frame now layers a real ComfyUI render for the selected
preset/light combo over the base image, with the REMIX slider acting as
a strength blend. The CSS grade drops to a subtle preview layer whenever
a render exists and remains the fallback for combos without one.
Renders are listed in heroGraphData (bakedRenderKeys) and preloaded per
active input; the meta bar credits 'Rendered with ComfyUI' when one is
showing. Variant selection moved into useHeroControls as shared state.
2026-07-05 02:09:33 +03:00
Marwan Ahmed
93cc41d233 fix(website): center hero headline, fit graph on screen, perf pass
- Centre the headline on the stage midpoint so it aligns with the screen,
  not the left-shifted graph
- Re-author node positions so the OUTPUT sits fully inside the stage; the
  scale-to-fit stage now keeps every node on screen at all desktop sizes
- Drag nodes via compositor-only translate3d instead of left/top layout
- Remove the animated SVG goo filter (continuous rasterization cost) in
  favour of static headline pills
- Drop per-node backdrop-blur
- Add a ComfyUI-style canvas dot grid and node title-bar status dots
2026-07-05 01:06:31 +03:00
Marwan Ahmed
13f4d3ac72 fix(website): apply hero graph review feedback
- Clamp node drags to an invisible stage bounding box so nodes hit the
  edge instead of getting cut off (OUTPUT keeps its home right-edge bleed)
- Keep dragged nodes consistently above the headline so layers no longer
  flip mid-drag over the title and subtitle
- Remove the yellow glow shadows on node hover/active and the OUTPUT live rim
- Remove the animated specular sheen sweeping across the output image
- Remove the cursor rollover that let the light drift toward the pointer
  on the output image
2026-07-04 22:27:22 +03:00
Marwan Mostafa
619d6d3bf9 feat(website): interactive color + lighting nodes drive the live hero output
Fill the empty Color Remixer and Lighting nodes with real controls (swatches,
mood presets, intensity sliders, a light-direction pad) that grade the OUTPUT
in real time via CSS overlays and filters. Add a live metadata bar, active node
glow, and connectors that pulse toward the output when a node is engaged.

Rework the mobile hero into a compact connected graph that fits one screen:
IMAGE forks into Color + Lighting which merge into the live output. Slow the
headline liquid link and respect prefers-reduced-motion throughout.
2026-06-30 13:41:57 +03:00
Marwan Mostafa
f6099e418b feat(website): tighten mobile hero to lead with output and remix picker
Collapse the mobile hero into a single OUTPUT card that leads with the
result and keeps the input thumbnails directly beneath it, so a tap swaps
the visible output with no scroll. Add a remix hint and thin the headline's
liquid connector.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
95d5127d5b feat(website): drag hero nodes and animate the split headline
- Make graph nodes draggable with live-routed wires; suppress the native
  image drag-out on the IMAGE node while keeping its picker clickable
- Rewire inputs into an image->texture->color->lighting->output chain and
  route side-port wires horizontally; extract the wire geometry into a
  tested heroGraphWires module
- Split the headline into two highlighter lines joined by a liquid link
  that glides side to side through the goo filter
- Nudge the whole graph left so the OUTPUT node bleeds less to the right
2026-06-30 13:41:56 +03:00
Marwan Mostafa
7a145d2fc6 fix(website): liquid headline, smoother wires, less output bleed
Move the gooey liquid effect onto the headline so the two highlighter blocks
merge into one liquid shape (crisp text layered over a goo-filtered yellow
backing); the IMAGE->TEXTURE link is a plain wire again. Cap the wire control
offsets so the splines stay smooth without twirling. Pull the OUTPUT node in so
most of it stays on screen, and keep the headline optically centered.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
a7cc2f87bf fix(website): rebalance hero layout, center headline, liquid links
Fill the viewport width on large screens instead of centering with margins, so
the IMAGE node sits near the left edge and the OUTPUT bleeds off the right
(no more right-leaning gap). Widen the design stage and move the headline into
the clear gap between IMAGE and OUTPUT, with the COLOR/LIGHTING placeholders
dropped to the lower band so nothing overlaps the copy. Optically center the
headline text inside the highlighter blocks. Replace the wiggly bezier wires
with tangent-aware S-curves plus endpoint dots, and render the IMAGE->TEXTURE
connection as a gooey liquid link.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
969e32afc1 feat(website): make hero responsive across all screens
Lay the desktop node graph out in a fixed 1440x760 design stage that scales
as a single unit to fit any width, so nodes and wires never collide and the
OUTPUT bleed is preserved proportionally from 1024px up. Wires are measured in
design coordinates via layout offsets, independent of the scale transform.
Extract the headline into HeroHeadline (shared by both layouts) and give phones
and tablets a centered, width-capped stacked column.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
949af59c4f feat(website): interactive node-graph hero
Replace the homepage hero with a free-floating node-graph behind a yellow
highlighter headline. The IMAGE node is a hover-driven image picker: hovering
(or focusing) a thumbnail crossfades the large input and cascades the matching
result through the OUTPUT node, with a smooth chromatic transition.

- New HeroGraph/HeroGraphNode/HeroImagePicker components and heroGraphData
- SVG bezier wires anchored to measured node rects, recomputed on resize
- Per-variant input/output image pairs; graceful stacked mobile layout
- Remove the old canvas hero (useHeroLogo) and the unused three dependency
- e2e coverage for hover/keyboard selection and the output cascade
2026-06-30 13:41:55 +03:00
683 changed files with 12477 additions and 22799 deletions

86
.claude/settings.json Normal file
View 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"
}
]
}
]
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -18,45 +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]
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // 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 }}
@@ -72,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: |

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",
"**/__fixtures__/**/*.json",
"apps/website/src/content/**/*.mdx"
"**/__fixtures__/**/*.json"
]
}

View File

@@ -1,5 +1,4 @@
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
@@ -25,9 +24,6 @@ export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
// quotes into curly ones and drift from the rest of the site's copy.
markdown: { smartypants: false },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
@@ -41,7 +37,6 @@ export default defineConfig({
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [
vue(),
mdx(),
sitemap({
filter: (page) => !isExcludedFromSitemap(page)
})

View File

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

View File

@@ -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 }) => {

View File

@@ -1,73 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customer story detail @smoke', () => {
test('renders the migrated article: hero, section nav, and body', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
await expect(
page.getByRole('heading', {
level: 1,
name: /Series Entertainment Rebuilt Game and Video Production/i
})
).toBeVisible()
const nav = page.getByRole('navigation', { name: 'Category filter' })
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
// Section title rendered from the MDX <Section title> wrapper.
await expect(
page.getByRole('heading', {
name: 'The Output Series Achieved Using ComfyUI'
})
).toBeVisible()
})
test('section nav highlights the section the reader selects', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
const nav = page.getByRole('navigation', { name: 'Category filter' })
const intro = nav.getByRole('button', { name: 'INTRO' })
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
await expect(intro).toHaveAttribute('aria-pressed', 'true')
await problem.click()
await expect(problem).toHaveAttribute('aria-pressed', 'true')
})
test('shows the read-more link only when an external source exists', async ({
page
}) => {
await page.goto('/customers/open-story-movement')
await expect(
page.getByRole('link', { name: /read more on this topic/i })
).toBeVisible()
// series-entertainment only redirected back to itself, so the link is gone.
await page.goto('/customers/series-entertainment')
await expect(
page.getByRole('link', { name: /read more on this topic/i })
).toHaveCount(0)
})
test('links to the next story in the what-is-next section', async ({
page
}) => {
await page.goto('/customers/series-entertainment')
const nextLink = page.getByRole('link', { name: /view article/i })
await expect(nextLink).toBeVisible()
// Links to another customer story, without coupling the test to the
// specific slug or sort order.
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
await expect(nextLink).not.toHaveAttribute(
'href',
'/customers/series-entertainment'
)
})
})

View File

@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -153,6 +154,121 @@ test.describe('Product showcase accordion @interaction', () => {
})
})
test.describe('Hero image picker @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
// Exclude the glitch swap's outgoing image, which lingers in the DOM until
// the transition settles, so the locator always resolves to the current one.
const activeImage = (page: Page) =>
page.locator(
'[data-testid="hero-active-image"]:visible:not(.hero-glitch-leave-active)'
)
const outputImage = (page: Page) =>
page.locator(
'[data-testid="hero-output-image"]:visible:not(.hero-glitch-leave-active)'
)
test('defaults to the portrait variant with its thumbnail selected', async ({
page
}) => {
await expect(activeImage(page)).toHaveAttribute(
'src',
/input-portrait\.png/
)
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-cyberpunk\.png/
)
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'true')
})
test('hovering a thumbnail swaps the large image and moves selection', async ({
page
}) => {
const deerThumb = page.getByRole('button', { name: /deer/i })
await deerThumb.hover()
await expect(activeImage(page)).toHaveAttribute('src', /input-deer\.png/)
await expect(deerThumb).toHaveAttribute('aria-pressed', 'true')
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'false')
})
test('selecting an input cascades the matching output', async ({ page }) => {
await page.getByRole('button', { name: /vase/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-vase\.png/
)
await page.getByRole('button', { name: /deer/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-deer\.png/
)
})
test('thumbnails are keyboard operable', async ({ page }) => {
const mirrorThumb = page.getByRole('button', { name: /mirror/i })
await mirrorThumb.focus()
await page.keyboard.press('Enter')
await expect(activeImage(page)).toHaveAttribute('src', /input-mirror\.png/)
await expect(mirrorThumb).toHaveAttribute('aria-pressed', 'true')
})
})
test.describe('Hero graph drag bounds @interaction', () => {
async function dragNodeTo(page: Page, node: Locator, x: number, y: number) {
const box = (await node.boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(x, y, { steps: 15 })
await page.mouse.up()
}
test('a dragged node stops at the stage edges instead of escaping', async ({
page,
viewport
}) => {
await page.goto('/')
const node = page.locator('[data-node="texture"]')
await expect(node).toBeVisible()
const stageBox = (await page.getByTestId('hero-stage').boundingBox())!
await dragNodeTo(page, node, 1, viewport!.height - 1)
await expect
.poll(async () => {
const box = (await node.boundingBox())!
return {
atLeftEdge: Math.abs(box.x - stageBox.x) <= 2,
atBottomEdge:
Math.abs(box.y + box.height - (stageBox.y + stageBox.height)) <= 2
}
})
.toEqual({ atLeftEdge: true, atBottomEdge: true })
await dragNodeTo(page, node, viewport!.width - 1, 1)
await expect
.poll(async () => {
const box = (await node.boundingBox())!
return {
atRightEdge:
Math.abs(box.x + box.width - (stageBox.x + stageBox.width)) <= 2,
atTopEdge: Math.abs(box.y - stageBox.y) <= 2
}
})
.toEqual({ atRightEdge: true, atTopEdge: true })
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -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:",
@@ -35,13 +34,11 @@
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@astrojs/mdx": "catalog:",
"@astrojs/vue": "catalog:",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
@@ -50,7 +47,6 @@
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vue-component-type-helpers": "catalog:"
"vitest": "catalog:"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
<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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }
]
},
{
@@ -86,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}

View File

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

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import { useEventListener, useIntersectionObserver } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
type Category = ComponentProps<typeof CategoryNav>['categories'][number]
const { categories } = defineProps<{
categories: Category[]
}>()
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = categories[categories.length - 1]?.value
if (lastId) activeSection.value = lastId
}
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock
})
return
}
clearScrollLock()
}
onMounted(() => {
// The section anchors live in the statically rendered article body, so the
// observer targets are resolved from the DOM by id rather than template refs.
const elements = categories
.map((category) => document.getElementById(category.value))
.filter((el): el is HTMLElement => el !== null)
useIntersectionObserver(
elements,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
best = entry
}
if (best) activeSection.value = best.target.id
},
{ rootMargin: '-20% 0px -60% 0px' }
)
activateLastIfAtBottom()
})
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
</script>
<template>
<CategoryNav
:categories
:model-value="activeSection"
@update:model-value="scrollToSection"
/>
</template>

View File

@@ -1,68 +0,0 @@
---
import { render } from 'astro:content'
import type { Locale } from '../../i18n/translations'
import type { CustomerStoryEntry } from '../../utils/customers'
import ArticleNav from './ArticleNav.vue'
import BulletList from './content/BulletList.astro'
import Contributors from './content/Contributors.astro'
import Figure from './content/Figure.astro'
import Heading from './content/Heading.astro'
import ListItem from './content/ListItem.astro'
import Paragraph from './content/Paragraph.astro'
import Quote from './content/Quote.astro'
import ReadMore from './content/ReadMore.vue'
import Section from './content/Section.astro'
import Steps from './content/Steps.astro'
interface Props {
entry: CustomerStoryEntry
locale?: Locale
}
const { entry, locale = 'en' } = Astro.props
const { Content } = await render(entry)
// The sidebar nav mirrors the section outline declared in frontmatter so it is
// server-rendered, exactly like the previous ContentSection sidebar.
const categories = entry.data.sections.map((section) => ({
label: section.label,
value: section.id
}))
// Markdown elements are mapped to the ported block styles; the named
// components (Section, Figure, ...) are used directly inside the MDX body.
const contentComponents = {
p: Paragraph,
h3: Heading,
ul: BulletList,
li: ListItem,
Section,
Figure,
Quote,
Contributors,
Steps
}
---
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<div class="lg:flex lg:gap-16">
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">
<ArticleNav
categories={categories}
client:media="(min-width: 1024px)"
/>
</div>
</aside>
<div class="flex-1">
<Content components={contentComponents} />
{
entry.data.readMore && (
<ReadMore href={entry.data.readMore} locale={locale} />
)
}
</div>
</div>
</section>

View File

@@ -1,12 +1,9 @@
<script setup lang="ts">
import { customerStories } from '../../config/customerStories'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { StoryCard } from '../../utils/customers'
const { stories, locale = 'en' } = defineProps<{
stories: StoryCard[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
</script>
@@ -16,7 +13,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in stories"
v-for="story in customerStories"
:key="story.slug"
:href="`${prefix}/customers/${story.slug}`"
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
@@ -25,7 +22,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
<div
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
:style="{ backgroundImage: `url(${story.cover})` }"
:style="{ backgroundImage: `url(${story.image})` }"
/>
</div>
@@ -35,12 +32,12 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<span
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
>
{{ story.category }}
{{ t(story.category, locale) }}
</span>
<h3
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
>
{{ story.title }}
{{ t(story.title, locale) }}
</h3>
</div>

View File

@@ -1 +0,0 @@
<ul class="mt-4 space-y-1 pl-5 text-sm"><slot /></ul>

View File

@@ -1,38 +0,0 @@
---
import { cn } from '@comfyorg/tailwind-utils'
interface Person {
name: string
role: string
}
interface Props {
label: string
people: Person[]
}
const { label, people } = Astro.props
---
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{label}
</span>
{
people.map((person, i) => (
<>
<p
class={cn(
'text-sm font-semibold text-primary-comfy-canvas',
i === 0 ? 'mt-2' : 'mt-4'
)}
>
{person.name}
</p>
<p class="text-xs text-primary-comfy-canvas">{person.role}</p>
</>
))
}
</div>

View File

@@ -1,20 +0,0 @@
---
interface Props {
src: string
alt: string
caption?: string
}
const { src, alt, caption } = Astro.props
---
<figure class="my-8">
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
{
caption && (
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
{caption}
</figcaption>
)
}
</figure>

View File

@@ -1,3 +0,0 @@
<h3 class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic">
<slot />
</h3>

View File

@@ -1,5 +0,0 @@
<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"
>
<slot />
</li>

View File

@@ -1 +0,0 @@
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>

View File

@@ -1,16 +0,0 @@
---
interface Props {
name: string
}
const { name } = Astro.props
---
<blockquote
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
>
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
"<slot />"
</p>
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
</blockquote>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import Button from '../../ui/button/Button.vue'
const { href, locale = 'en' } = defineProps<{
href: string
locale?: Locale
}>()
</script>
<template>
<div class="mt-8 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ t('customers.story.readMore', locale) }}
<template #append>
<span class="text-base" aria-hidden="true"></span>
</template>
</Button>
</div>
</template>

View File

@@ -1,17 +0,0 @@
---
interface Props {
id: string
title?: string
}
const { id, title } = Astro.props
---
<div id={id} class="mb-16 scroll-mt-24 lg:scroll-mt-36">
{
title && (
<h2 class="mb-6 text-2xl font-light text-primary-comfy-canvas">{title}</h2>
)
}
<slot />
</div>

View File

@@ -1,17 +0,0 @@
---
interface Props {
items: string[]
}
const { items } = Astro.props
---
<ol class="mt-4 space-y-1 pl-1 text-sm [counter-reset:step]">
{
items.map((item) => (
<li class="flex items-start gap-3 text-primary-comfy-canvas [counter-increment:step] before:shrink-0 before:font-semibold before:tabular-nums before:text-primary-comfy-yellow before:content-[counter(step,_decimal-leading-zero)]">
{item}
</li>
))
}
</ol>

View File

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

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { colorPresets, colorSwatches } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { colorPresetId, swatchId, colorIntensity } = controls
const remixLabel = computed(
() => `${t('hero.color.remix', locale)} ${colorIntensity.value}%`
)
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
class="flex gap-1.5"
role="group"
:aria-label="t('hero.color.palette', locale)"
>
<button
v-for="s in colorSwatches"
:key="s.id"
type="button"
:aria-pressed="s.id === swatchId"
:aria-label="t(s.labelKey, locale)"
:style="{ backgroundColor: `rgb(${s.rgb})` }"
:class="
cn(
'size-5 rounded-full ring-1 ring-white/20 transition-transform ring-inset',
s.id === swatchId
? 'scale-110 ring-2 ring-white/80'
: 'opacity-75 hover:opacity-100'
)
"
@pointerdown.stop
@click.stop="swatchId = s.id"
/>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="p in colorPresets"
:key="p.id"
type="button"
:aria-pressed="p.id === colorPresetId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
p.id === colorPresetId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="colorPresetId = p.id"
>
{{ t(p.labelKey, locale) }}
</button>
</div>
<label class="flex flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ remixLabel }}
</span>
<input
v-model.number="colorIntensity"
type="range"
min="0"
max="100"
:aria-label="remixLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,418 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import HeroColorNode from './HeroColorNode.vue'
import HeroGraphNode from './HeroGraphNode.vue'
import HeroHeadline from './HeroHeadline.vue'
import HeroImagePicker from './HeroImagePicker.vue'
import HeroLightingNode from './HeroLightingNode.vue'
import HeroOutputFrame from './HeroOutputFrame.vue'
import { imageVariants, textureImage } from './heroGraphData'
import {
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
homePositions
} from './heroGraphLayout'
import { computeWires } from './heroGraphWires'
import type { NodeId, Point, Rect } from './heroGraphWires'
import { useHeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const controls = useHeroControls()
const { activeNode, variantId } = controls
// The desktop graph is authored in a fixed design coordinate space and scaled
// as a single unit to fit the viewport width, so the whole composition stays on
// screen at every size. Node positions are live state so they can be dragged;
// widths are fixed per node and heights are measured once for wiring.
const MAX_SCALE = 1.3
const positions = ref<Record<NodeId, Point>>(structuredClone(homePositions))
const frameRef = ref<HTMLElement>()
const stageRef = ref<HTMLElement>()
const scale = ref(1)
const heights = ref<Record<string, number>>({})
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
// in unscaled design coordinates regardless of the stage's scale transform.
function measureHeights() {
const stage = stageRef.value
if (!stage) return
const next: Record<string, number> = {}
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
next[el.dataset.node ?? ''] = el.offsetHeight
})
heights.value = next
}
function updateScale() {
const width = frameRef.value?.clientWidth ?? STAGE_W
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
}
function refresh() {
updateScale()
measureHeights()
}
useResizeObserver(frameRef, refresh)
const stageStyle = computed(() => ({
width: `${STAGE_W}px`,
height: `${STAGE_H}px`,
transform: `translateX(-50%) scale(${scale.value})`
}))
function nodeStyle(id: NodeId) {
const { x, y } = positions.value[id]
return {
transform: `translate3d(${x}px, ${y}px, 0)`,
width: `${NODE_W[id]}px`
}
}
// Wires recompute from live positions + measured heights, so they track the
// nodes synchronously while dragging with no measure round-trip.
const anchors = computed<Record<NodeId, Rect>>(() => {
const ids = Object.keys(positions.value) as NodeId[]
return Object.fromEntries(
ids.map((id) => [
id,
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
])
) as Record<NodeId, Rect>
})
const dragging = ref<NodeId | null>(null)
let drag = { id: '' as NodeId, pointerId: -1, px: 0, py: 0, ox: 0, oy: 0 }
function onPointerDown(id: NodeId, e: PointerEvent) {
if (e.button !== 0) return
drag = {
id,
pointerId: e.pointerId,
px: e.clientX,
py: e.clientY,
ox: positions.value[id].x,
oy: positions.value[id].y
}
dragging.value = id
}
// A small threshold keeps taps on the image picker from registering as drags.
function onPointerMove(e: PointerEvent) {
if (dragging.value == null || e.pointerId !== drag.pointerId) return
const dx = e.clientX - drag.px
const dy = e.clientY - drag.py
if (Math.hypot(dx, dy) < 4) return
positions.value[drag.id] = clampNodePosition(
drag.id,
{ x: drag.ox + dx / scale.value, y: drag.oy + dy / scale.value },
heights.value[drag.id] ?? 0
)
}
function onPointerUp() {
dragging.value = null
}
// Listeners live on window so a drag continues even when the pointer outruns the
// node; registered in onMounted to keep window off the SSR path.
onMounted(() => {
void nextTick(refresh)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
})
const wires = computed(() => computeWires(anchors.value))
const dots = computed<{ p: Point; accent: boolean }[]>(() =>
wires.value.flatMap((w) => [
{ p: w.from, accent: w.accent },
{ p: w.to, accent: w.accent }
])
)
</script>
<template>
<div class="relative w-full">
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
<div
ref="frameRef"
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
>
<div
ref="stageRef"
data-testid="hero-stage"
class="absolute top-0 left-1/2 origin-top"
:style="stageStyle"
>
<svg
class="pointer-events-none absolute inset-0 size-full overflow-visible"
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
fill="none"
aria-hidden="true"
>
<path
v-for="(wire, i) in wires"
:key="i"
:d="wire.d"
:stroke="
wire.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.16)'
"
:stroke-width="wire.accent ? 2 : 1.5"
stroke-linecap="round"
/>
<circle
v-for="(dot, i) in dots"
:key="`d${i}`"
:cx="dot.p.x"
:cy="dot.p.y"
:r="dot.accent ? 4 : 3"
:fill="
dot.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.3)'
"
/>
<!-- Energy pulses that flow toward the OUTPUT while a control node is
engaged; idle-hidden via opacity, animated through CSS. -->
<g :class="cn(activeNode && 'hero-wire-active')">
<path
v-for="(wire, i) in wires"
:key="`p${i}`"
:d="wire.d"
class="hero-wire-pulse"
stroke="var(--color-primary-comfy-yellow)"
stroke-width="2.5"
stroke-linecap="round"
pathLength="1"
stroke-dasharray="0.18 0.82"
/>
</g>
</svg>
<!-- The headline stays beneath the nodes so a dragged window passes
cleanly over it instead of flipping layers mid-drag. -->
<div class="absolute top-[140px] left-[800px] -translate-x-1/2">
<HeroHeadline :locale />
</div>
<div
data-node="image"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'image' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('image')"
@pointerdown="onPointerDown('image', $event)"
>
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="variantId"
:locale
@select="(id) => (variantId = id)"
/>
</HeroGraphNode>
</div>
<div
data-node="texture"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'texture' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('texture')"
@pointerdown="onPointerDown('texture', $event)"
>
<HeroGraphNode :label="t('hero.node.texture', locale)" accent>
<div class="aspect-square w-full overflow-hidden rounded-xl">
<img
:src="textureImage.src"
:alt="t(textureImage.altKey, locale)"
draggable="false"
class="size-full object-cover"
/>
</div>
</HeroGraphNode>
</div>
<div
data-node="color"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'color' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('color')"
@pointerdown="onPointerDown('color', $event)"
@pointerenter="activeNode = 'color'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.color', locale)"
:active="activeNode === 'color'"
>
<HeroColorNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="lighting"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'lighting' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('lighting')"
@pointerdown="onPointerDown('lighting', $event)"
@pointerenter="activeNode = 'lighting'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.lighting', locale)"
:active="activeNode === 'lighting'"
>
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="output"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'output' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('output')"
@pointerdown="onPointerDown('output', $event)"
>
<HeroGraphNode :label="t('hero.node.output', locale)">
<HeroOutputFrame :controls :locale class="aspect-square" />
</HeroGraphNode>
</div>
</div>
</div>
<!-- Mobile / tablet: a compact connected graph that fits one screen the
IMAGE selector forks into COLOR + LIGHTING, which merge into the live
OUTPUT. Connectors are decorative SVGs aligned to the 2-column grid. -->
<div class="flex flex-col items-center px-5 pt-3 pb-8 lg:hidden">
<HeroHeadline :locale compact />
<div class="mt-3 w-full max-w-sm sm:max-w-md">
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="variantId"
:locale
hide-preview
thumb-class="h-14"
@select="(id) => (variantId = id)"
/>
</HeroGraphNode>
<div class="relative h-6 w-full" aria-hidden="true">
<svg
class="absolute inset-0 size-full"
viewBox="0 0 100 36"
preserveAspectRatio="none"
fill="none"
>
<path
d="M50 3 C 50 22 25 14 25 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M50 3 C 50 22 75 14 75 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="bg-primary-comfy-yellow absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
<span
class="absolute bottom-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute bottom-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
</div>
<div class="grid grid-cols-2 items-stretch gap-2">
<HeroGraphNode :label="t('hero.node.color', locale)">
<HeroColorNode :controls :locale />
</HeroGraphNode>
<HeroGraphNode :label="t('hero.node.lighting', locale)">
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<div class="relative h-6 w-full" aria-hidden="true">
<svg
class="absolute inset-0 size-full"
viewBox="0 0 100 36"
preserveAspectRatio="none"
fill="none"
>
<path
d="M25 2 C 25 18 50 14 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M75 2 C 75 18 50 14 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="absolute top-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute top-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="bg-primary-comfy-yellow absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
</div>
<HeroGraphNode :label="t('hero.node.output', locale)" accent>
<HeroOutputFrame :controls :locale class="h-[150px]" />
</HeroGraphNode>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const {
label,
accent = false,
active = false,
class: customClass = ''
} = defineProps<{
label: string
accent?: boolean
active?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'bg-transparency-ink-t80 rounded-2xl border p-3 transition-colors duration-500 ease-out',
accent ? 'border-primary-comfy-yellow' : 'border-white/10',
active && 'border-primary-comfy-yellow/70',
customClass
)
"
>
<span
class="text-primary-warm-gray flex items-center gap-1.5 pl-1 text-[0.7rem] font-medium tracking-[0.22em] uppercase"
>
<span
:class="
cn(
'size-1.5 rounded-full',
accent || active ? 'bg-primary-comfy-yellow' : 'bg-white/25'
)
"
/>
{{ label }}
</span>
<div class="mt-2">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en', compact = false } = defineProps<{
locale?: Locale
compact?: boolean
}>()
const lines = computed(() => t('hero.title', locale).split('\n'))
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-2'))
const pill =
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
// optically centered inside the highlighter block.
const inner = 'relative top-[0.06em] inline-block'
</script>
<template>
<div class="flex flex-col items-center text-center">
<h1 class="flex flex-col items-center">
<span
:class="
cn(pill, size, 'bg-primary-comfy-yellow text-primary-comfy-ink')
"
>
<span :class="inner">{{ lines[0] }}</span>
</span>
<span
:class="
cn(
pill,
size,
'bg-primary-comfy-yellow text-primary-comfy-ink',
lineGap
)
"
>
<span :class="inner">{{ lines[1] }}</span>
</span>
</h1>
<p
:class="
cn(
'max-w-md text-primary-comfy-canvas',
compact ? 'mt-5 hidden text-sm/relaxed sm:block' : 'mt-8 text-base'
)
"
>
{{ t('hero.subtitle', locale) }}
</p>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { ImageVariant } from './heroGraphData'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
variants,
activeId,
locale = 'en',
previewSrc,
previewAlt,
previewTestId = 'hero-active-image',
hint,
hidePreview = false,
thumbClass = 'aspect-square'
} = defineProps<{
variants: readonly ImageVariant[]
activeId: string
locale?: Locale
// Override the large preview with the result the active input produces, so the
// mobile card can lead with the OUTPUT while the thumbnails stay the selector.
previewSrc?: string
previewAlt?: string
previewTestId?: string
hint?: string
// Render only the thumbnail strip, e.g. beneath a standalone output frame.
hidePreview?: boolean
// Sizing for each thumbnail; defaults to square, overridable for tight rows.
thumbClass?: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const active = computed(
() => variants.find((v) => v.id === activeId) ?? variants[0]
)
const preview = computed(() => ({
src: previewSrc ?? active.value.src,
alt: previewAlt ?? t(active.value.altKey, locale)
}))
// Hover (mouse) and focus (keyboard) both swap the active variant. Click stays
// as the activation path for touch, where there is no hover.
function selectOnHover(id: string, e: PointerEvent) {
if (e.pointerType === 'mouse') emit('select', id)
}
</script>
<template>
<div>
<div
v-if="!hidePreview"
class="relative aspect-square w-full overflow-hidden rounded-xl"
>
<Transition name="hero-glitch">
<img
:key="preview.src"
:src="preview.src"
:alt="preview.alt"
:data-testid="previewTestId"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<p v-if="hint" class="text-primary-warm-gray pl-1 text-xs/relaxed">
{{ hint }}
</p>
<div
class="mt-2 flex gap-2"
role="group"
:aria-label="t('hero.image.pickerLabel', locale)"
>
<button
v-for="variant in variants"
:key="variant.id"
type="button"
:aria-pressed="variant.id === activeId"
:aria-label="t(variant.altKey, locale)"
:class="
cn(
'focus-visible:outline-primary-comfy-yellow relative flex-1 overflow-hidden rounded-md transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2',
thumbClass,
variant.id === activeId
? 'ring-primary-comfy-yellow opacity-100 ring-2'
: 'opacity-50 hover:opacity-90'
)
"
@pointerenter="selectOnHover(variant.id, $event)"
@focus="emit('select', variant.id)"
@click="emit('select', variant.id)"
>
<img
:src="variant.src"
alt=""
aria-hidden="true"
draggable="false"
class="size-full object-cover"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { lightModes } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { lightModeId, lightIntensity, lightDir, setLightFromUnit } = controls
const intensityLabel = computed(
() => `${t('hero.light.intensity', locale)} ${lightIntensity.value}%`
)
const dotStyle = computed(() => ({
left: `${lightDir.value.x * 100}%`,
top: `${lightDir.value.y * 100}%`
}))
const pad = ref<HTMLElement>()
const dragging = ref(false)
function applyFromEvent(e: PointerEvent) {
const el = pad.value
if (!el) return
const r = el.getBoundingClientRect()
setLightFromUnit(
(e.clientX - r.left) / r.width,
(e.clientY - r.top) / r.height
)
}
function onDown(e: PointerEvent) {
dragging.value = true
pad.value?.setPointerCapture(e.pointerId)
applyFromEvent(e)
}
function onMove(e: PointerEvent) {
if (dragging.value) applyFromEvent(e)
}
function onUp(e: PointerEvent) {
dragging.value = false
pad.value?.releasePointerCapture?.(e.pointerId)
}
</script>
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3">
<div
ref="pad"
class="relative size-12 shrink-0 touch-none rounded-full bg-white/5 ring-1 ring-white/10 ring-inset"
:aria-label="t('hero.light.direction', locale)"
@pointerdown.stop.prevent="onDown"
@pointermove.stop="onMove"
@pointerup.stop="onUp"
@pointercancel.stop="onUp"
>
<div
class="hero-light-glow pointer-events-none absolute inset-0 rounded-full"
/>
<div
class="bg-primary-comfy-yellow pointer-events-none absolute size-3 -translate-1/2 rounded-full shadow-[0_0_10px_2px_rgb(242_255_89/0.6)]"
:style="dotStyle"
/>
</div>
<label class="flex flex-1 flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ intensityLabel }}
</span>
<input
v-model.number="lightIntensity"
type="range"
min="0"
max="100"
:aria-label="intensityLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="m in lightModes"
:key="m.id"
type="button"
:aria-pressed="m.id === lightModeId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
m.id === lightModeId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="lightModeId = m.id"
>
{{ t(m.labelKey, locale) }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
controls,
locale = 'en',
class: customClass = ''
} = defineProps<{
controls: HeroControls
locale?: Locale
class?: HTMLAttributes['class']
}>()
const {
variant,
bakedRender,
colorIntensity,
outputFilter,
colorLayerStyle,
lightLayerStyle,
lightMode
} = controls
const metaText = computed(() => {
const grade = bakedRender.value
? t('hero.output.rendered', locale)
: t('hero.output.grade', locale)
return `${grade} · ${t('hero.output.colorActive', locale)} · ${t(lightMode.value.labelKey, locale)} ${t('hero.output.lightingSuffix', locale)}`
})
</script>
<template>
<div :class="cn('relative w-full overflow-hidden rounded-xl', customClass)">
<div class="relative size-full" :style="{ filter: outputFilter }">
<Transition name="hero-glitch">
<img
:key="variant.output.src"
:src="variant.output.src"
:alt="t(variant.output.altKey, locale)"
data-testid="hero-output-image"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
<!-- Real ComfyUI render for the selected combo; the REMIX slider blends
it over the base output like a strength control. -->
<Transition name="hero-glitch">
<img
v-if="bakedRender"
:key="bakedRender.src"
:src="bakedRender.src"
alt=""
aria-hidden="true"
data-testid="hero-baked-render"
draggable="false"
class="absolute inset-0 size-full object-cover"
:style="{ opacity: colorIntensity / 100 }"
/>
</Transition>
</div>
<div
class="pointer-events-none absolute inset-0 transition-opacity duration-500"
:style="colorLayerStyle"
/>
<div
class="pointer-events-none absolute inset-0 mix-blend-screen transition-opacity duration-500"
:style="lightLayerStyle"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-linear-to-t from-black/70 to-transparent px-3 pt-6 pb-2"
>
<span class="truncate font-mono text-[0.6rem] text-white/70">
{{ metaText }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span
class="hero-live-dot bg-primary-comfy-yellow size-1.5 rounded-full"
/>
<span
class="text-[0.6rem] font-medium tracking-wide whitespace-nowrap text-white/70 uppercase"
>
{{ t('hero.output.live', locale) }}
</span>
</span>
</div>
</div>
</template>

View File

@@ -1,55 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import HeroGraph from './HeroGraph.vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
/>
</div>
<div class="flex-1 px-6 py-12 lg:px-16">
<h1
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
>
{{ t('hero.title', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>
</div>
<section class="hero-dot-grid relative overflow-hidden bg-primary-comfy-ink">
<HeroGraph :locale />
</section>
</template>

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { resolveBakedRender } from './heroGraphData'
describe('resolveBakedRender', () => {
const keys = new Set([
'portrait/cyberpunk',
'portrait/cyberpunk/neon',
'vase/film'
])
it('prefers the exact preset + light-mode render', () => {
expect(resolveBakedRender('portrait', 'cyberpunk', 'neon', keys)).toEqual({
src: '/images/hero/output-portrait-cyberpunk-neon.webp',
includesLight: true
})
})
it('falls back to the preset-only render for unlisted light modes', () => {
expect(resolveBakedRender('portrait', 'cyberpunk', 'rim', keys)).toEqual({
src: '/images/hero/output-portrait-cyberpunk.webp',
includesLight: false
})
})
it('returns null when no render exists for the combo', () => {
expect(resolveBakedRender('deer', 'dream', 'soft', keys)).toBeNull()
expect(resolveBakedRender('vase', 'cyberpunk', 'neon', keys)).toBeNull()
})
})

View File

@@ -0,0 +1,98 @@
import type { TranslationKey } from '../../i18n/translations'
interface NodeImage {
src: string
altKey: TranslationKey
}
export interface ImageVariant extends NodeImage {
id: string
// Names the input in baked-render filenames (output-<key>-<preset>….webp).
key: string
// The result this input produces, shown in the OUTPUT node. Swapping the
// input cascades its matching output through the graph.
output: NodeImage
}
export const imageVariants = [
{
id: 'v1',
key: 'portrait',
src: '/images/hero/input-portrait.png',
altKey: 'hero.image.variant1',
output: {
src: '/images/hero/output-cyberpunk.png',
altKey: 'hero.output.variant1'
}
},
{
id: 'v2',
key: 'vase',
src: '/images/hero/input-vase.png',
altKey: 'hero.image.variant2',
output: {
src: '/images/hero/output-vase.png',
altKey: 'hero.output.variant2'
}
},
{
id: 'v3',
key: 'deer',
src: '/images/hero/input-deer.png',
altKey: 'hero.image.variant3',
output: {
src: '/images/hero/output-deer.png',
altKey: 'hero.output.variant3'
}
},
{
id: 'v4',
key: 'mirror',
src: '/images/hero/input-mirror.png',
altKey: 'hero.image.variant4',
output: {
src: '/images/hero/output-mirror.png',
altKey: 'hero.output.variant4'
}
}
] as const satisfies readonly ImageVariant[]
export const textureImage: NodeImage = {
src: '/images/hero/input-vase.png',
altKey: 'hero.image.texture'
}
// Real ComfyUI renders for control combinations, listed as they are generated
// and dropped into public/images/hero/. Keys are `<variant>/<preset>` or
// `<variant>/<preset>/<lightMode>`; the matching file is
// `output-<variant>-<preset>[-<lightMode>].webp`. Combos not listed here fall
// back to the base output with the CSS preview grade.
const bakedRenderKeys: ReadonlySet<string> = new Set()
export interface BakedRender {
src: string
// True when the render already contains the selected light mode, so the CSS
// light overlay only needs to hint at direction instead of faking the look.
includesLight: boolean
}
export function resolveBakedRender(
variantKey: string,
presetId: string,
lightModeId: string,
keys: ReadonlySet<string> = bakedRenderKeys
): BakedRender | null {
if (keys.has(`${variantKey}/${presetId}/${lightModeId}`)) {
return {
src: `/images/hero/output-${variantKey}-${presetId}-${lightModeId}.webp`,
includesLight: true
}
}
if (keys.has(`${variantKey}/${presetId}`)) {
return {
src: `/images/hero/output-${variantKey}-${presetId}.webp`,
includesLight: false
}
}
return null
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
homePositions
} from './heroGraphLayout'
describe('clampNodePosition', () => {
it('leaves positions inside the stage untouched', () => {
expect(clampNodePosition('color', { x: 400, y: 300 }, 180)).toEqual({
x: 400,
y: 300
})
})
it('stops nodes at the stage edges instead of letting them escape', () => {
expect(clampNodePosition('color', { x: -80, y: -50 }, 180)).toEqual({
x: 0,
y: 0
})
expect(clampNodePosition('color', { x: 5000, y: 5000 }, 180)).toEqual({
x: STAGE_W - NODE_W.color,
y: STAGE_H - 180
})
})
it('keeps the node bottom above the stage floor so it never gets cut off', () => {
const height = 540
const { y } = clampNodePosition('output', { x: 1080, y: 700 }, height)
expect(y + height).toBeLessThanOrEqual(STAGE_H)
})
it('accepts every home position unchanged', () => {
for (const [id, home] of Object.entries(homePositions)) {
const nodeId = id as keyof typeof homePositions
expect(clampNodePosition(nodeId, home, 100)).toEqual(home)
expect(home.x + NODE_W[nodeId]).toBeLessThanOrEqual(STAGE_W)
}
})
})

View File

@@ -0,0 +1,37 @@
import { clamp } from 'es-toolkit'
import type { NodeId, Point } from './heroGraphWires'
export const STAGE_W = 1600
export const STAGE_H = 780
export const NODE_W: Record<NodeId, number> = {
image: 300,
texture: 190,
color: 210,
lighting: 210,
output: 500
}
// Inputs stack on the left, the control chain runs under the centred headline,
// and the OUTPUT sits fully inside the right edge so nothing bleeds offscreen.
export const homePositions: Record<NodeId, Point> = {
image: { x: 24, y: 40 },
texture: { x: 60, y: 545 },
color: { x: 396, y: 486 },
lighting: { x: 650, y: 486 },
output: { x: 1080, y: 126 }
}
// Drags are confined to an invisible bounding box — the stage rect — so every
// node stops at the edge instead of getting cut off.
export function clampNodePosition(
id: NodeId,
point: Point,
height: number
): Point {
return {
x: clamp(point.x, 0, STAGE_W - NODE_W[id]),
y: clamp(point.y, 0, STAGE_H - height)
}
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NodeId, Rect } from './heroGraphWires'
import { computeWires, connections, spline } from './heroGraphWires'
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
function controlPoints(d: string) {
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
.replace(/[MC]/g, ' ')
.trim()
.split(/\s+/)
.map(Number)
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
}
describe('spline', () => {
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
// A short rightward run with a large vertical rise: must still leave/enter
// along x so it reads left-to-right, not as a top-down drop.
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
)
expect(c1y).toBe(sy)
expect(c2y).toBe(ey)
expect(c1x).toBeGreaterThan(sx)
expect(c2x).toBeLessThan(ex)
})
it('departs and arrives vertically for stacked ports', () => {
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 100, y: 0 }, { x: 140, y: 400 }, 'v')
)
expect(c1x).toBe(sx)
expect(c2x).toBe(ex)
expect(c1y).toBeGreaterThan(sy)
expect(c2y).toBeLessThan(ey)
})
})
describe('connections', () => {
it('feeds the color remixer from texture, not directly from image', () => {
const pairs = connections.map((c) => `${c.from}->${c.to}`)
expect(pairs).toContain('texture->color')
expect(pairs).not.toContain('image->color')
})
it('chains image → texture → color → lighting → output', () => {
expect(connections.map((c) => `${c.from}->${c.to}`)).toEqual([
'image->texture',
'texture->color',
'color->lighting',
'lighting->output'
])
})
})
describe('computeWires', () => {
const anchors: Record<NodeId, Rect> = {
image: { x: 60, y: 28, w: 300, h: 395 },
texture: { x: 96, y: 500, w: 200, h: 225 },
color: { x: 470, y: 470, w: 150, h: 180 },
lighting: { x: 720, y: 500, w: 168, h: 179 },
output: { x: 1000, y: 110, w: 760, h: 611 }
}
it('only emits wires for nodes that have been measured', () => {
expect(computeWires({ image: anchors.image }).length).toBe(0)
expect(computeWires(anchors).length).toBe(connections.length)
})
it('marks the image → texture wire as the accent wire', () => {
const wires = computeWires(anchors)
expect(wires[0].accent).toBe(true)
expect(wires.slice(1).every((w) => !w.accent)).toBe(true)
})
})

View File

@@ -0,0 +1,110 @@
export type NodeId = 'image' | 'texture' | 'color' | 'lighting' | 'output'
export interface Point {
x: number
y: number
}
export interface Rect extends Point {
w: number
h: number
}
export interface Wire {
d: string
from: Point
to: Point
accent: boolean
}
type Axis = 'h' | 'v'
type Port = (r: Rect) => Point
const rightPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
const leftPort =
(f = 0.5): Port =>
(r) => ({ x: r.x, y: r.y + r.h * f })
const bottomPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y + r.h })
const topPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y })
function clampOffset(d: number): number {
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
}
// Soft cubic whose tangents follow the connected ports: side ports (right→left)
// depart horizontally, stacked ports (bottom→top) depart vertically. Keeping the
// tangent on the port axis stops a wire between side ports from reading as a
// top-down drop when the vertical gap happens to exceed the horizontal one.
export function spline(s: Point, e: Point, axis: Axis): string {
if (axis === 'h') {
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
}
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
}
interface Connection {
from: NodeId
to: NodeId
fromPort: Port
toPort: Port
axis: Axis
accent?: boolean
}
export const connections: Connection[] = [
{
from: 'image',
to: 'texture',
fromPort: bottomPort(0.4),
toPort: topPort(0.5),
axis: 'v',
accent: true
},
{
from: 'texture',
to: 'color',
fromPort: rightPort(0.5),
toPort: leftPort(0.5),
axis: 'h'
},
{
from: 'color',
to: 'lighting',
fromPort: rightPort(0.5),
toPort: leftPort(0.45),
axis: 'h'
},
{
from: 'lighting',
to: 'output',
fromPort: rightPort(0.4),
toPort: leftPort(0.4),
axis: 'h'
}
]
export function computeWires(anchors: Partial<Record<NodeId, Rect>>): Wire[] {
return connections.flatMap((c) => {
const fr = anchors[c.from]
const to = anchors[c.to]
if (!fr || !to) return []
const from = c.fromPort(fr)
const dest = c.toPort(to)
return [
{
from,
to: dest,
accent: c.accent ?? false,
d: spline(from, dest, c.axis)
}
]
})
}

View File

@@ -0,0 +1,244 @@
import { clamp } from 'es-toolkit'
import { computed, onMounted, ref, watchEffect } from 'vue'
import type { CSSProperties } from 'vue'
import { imageVariants, resolveBakedRender } from './heroGraphData'
import type { Point } from './heroGraphWires'
import type { TranslationKey } from '../../i18n/translations'
type ColorPresetId = 'cyberpunk' | 'film' | 'dream' | 'editorial'
type LightModeId = 'soft' | 'rim' | 'neon' | 'studio'
type HeroNodeId = 'color' | 'lighting'
interface ColorPreset {
id: ColorPresetId
labelKey: TranslationKey
saturate: number
contrast: number
hue: number
blend: CSSProperties['mixBlendMode']
}
interface ColorSwatch {
id: string
labelKey: TranslationKey
// Space-separated RGB channels, ready for `rgb(<rgb> / <alpha>)`.
rgb: string
}
interface LightMode {
id: LightModeId
labelKey: TranslationKey
spread: number
core: number
rim: number
tint: string
}
export const colorPresets: ColorPreset[] = [
{
id: 'cyberpunk',
labelKey: 'hero.color.preset.cyberpunk',
saturate: 1.3,
contrast: 1.1,
hue: 8,
blend: 'overlay'
},
{
id: 'film',
labelKey: 'hero.color.preset.film',
saturate: 0.9,
contrast: 1.2,
hue: -6,
blend: 'overlay'
},
{
id: 'dream',
labelKey: 'hero.color.preset.dream',
saturate: 1.2,
contrast: 0.95,
hue: 22,
blend: 'soft-light'
},
{
id: 'editorial',
labelKey: 'hero.color.preset.editorial',
saturate: 0.78,
contrast: 1.08,
hue: 0,
blend: 'overlay'
}
]
export const colorSwatches: ColorSwatch[] = [
{ id: 'magenta', labelKey: 'hero.color.swatch.magenta', rgb: '255 0 140' },
{ id: 'cyan', labelKey: 'hero.color.swatch.cyan', rgb: '0 209 255' },
{ id: 'lime', labelKey: 'hero.color.swatch.lime', rgb: '170 255 90' },
{ id: 'amber', labelKey: 'hero.color.swatch.amber', rgb: '255 176 74' },
{ id: 'violet', labelKey: 'hero.color.swatch.violet', rgb: '150 110 255' }
]
export const lightModes: LightMode[] = [
{
id: 'soft',
labelKey: 'hero.light.mode.soft',
spread: 72,
core: 0.4,
rim: 0,
tint: '255 244 224'
},
{
id: 'rim',
labelKey: 'hero.light.mode.rim',
spread: 56,
core: 0.3,
rim: 0.55,
tint: '176 214 255'
},
{
id: 'neon',
labelKey: 'hero.light.mode.neon',
spread: 62,
core: 0.5,
rim: 0.3,
tint: '206 120 255'
},
{
id: 'studio',
labelKey: 'hero.light.mode.studio',
spread: 88,
core: 0.62,
rim: 0,
tint: '255 255 255'
}
]
// Centralizes the hero's interactive grade so the desktop graph and the mobile
// column drive the same OUTPUT overlays. Returns plain refs (mutated directly by
// the node controls) plus computed CSS the output frame binds inline.
export function useHeroControls() {
const variantId = ref<string>(imageVariants[0].id)
const variant = computed(
() =>
imageVariants.find((v) => v.id === variantId.value) ?? imageVariants[0]
)
const colorPresetId = ref<ColorPresetId>('cyberpunk')
const swatchId = ref(colorSwatches[0].id)
const colorIntensity = ref(72)
const lightModeId = ref<LightModeId>('neon')
const lightIntensity = ref(58)
const lightDir = ref<Point>({ x: 0.64, y: 0.3 })
const activeNode = ref<HeroNodeId | null>(null)
const colorPreset = computed(
() =>
colorPresets.find((p) => p.id === colorPresetId.value) ?? colorPresets[0]
)
const swatch = computed(
() => colorSwatches.find((s) => s.id === swatchId.value) ?? colorSwatches[0]
)
const lightMode = computed(
() => lightModes.find((m) => m.id === lightModeId.value) ?? lightModes[0]
)
// A real ComfyUI render for the selected combo, when one has been generated.
// While it is showing, the CSS grade steps back to a subtle preview layer so
// the output quality is the render's, not a filter approximation.
const bakedRender = computed(() =>
resolveBakedRender(
variant.value.key,
colorPresetId.value,
lightModeId.value
)
)
const outputFilter = computed(() => {
const l = lightIntensity.value / 100
const brightness = 1 + l * (lightMode.value.core - 0.3) * 0.45
if (bakedRender.value) return `brightness(${brightness.toFixed(3)})`
const c = colorIntensity.value / 100
const p = colorPreset.value
const saturate = 1 + (p.saturate - 1) * c
const contrast = 1 + (p.contrast - 1) * c
const hue = p.hue * c
return `saturate(${saturate.toFixed(3)}) contrast(${contrast.toFixed(3)}) hue-rotate(${hue.toFixed(1)}deg) brightness(${brightness.toFixed(3)})`
})
const colorLayerStyle = computed<CSSProperties>(() => ({
backgroundImage: `linear-gradient(125deg, rgb(${swatch.value.rgb} / 0.55), rgb(${swatch.value.rgb} / 0.06) 72%)`,
mixBlendMode: colorPreset.value.blend,
opacity: (colorIntensity.value / 100) * (bakedRender.value ? 0.15 : 0.5)
}))
const lightLayerStyle = computed<CSSProperties>(() => {
const m = lightMode.value
const x = (lightDir.value.x * 100).toFixed(1)
const y = (lightDir.value.y * 100).toFixed(1)
const layers = [
`radial-gradient(circle at ${x}% ${y}%, rgb(${m.tint} / 0.9), transparent ${m.spread}%)`
]
if (m.rim > 0) {
layers.push(
`linear-gradient(105deg, transparent 58%, rgb(${m.tint} / ${m.rim}))`
)
}
return {
backgroundImage: layers.join(', '),
opacity:
(lightIntensity.value / 100) *
(bakedRender.value?.includesLight ? 0.3 : 0.72)
}
})
// Warm the cache for the active input's other renders so preset/light swaps
// land instantly instead of flashing while the file loads.
onMounted(() => {
watchEffect(() => {
for (const preset of colorPresets) {
for (const mode of lightModes) {
const baked = resolveBakedRender(
variant.value.key,
preset.id,
mode.id
)
if (baked) new Image().src = baked.src
}
}
})
})
function setLightFromUnit(x: number, y: number) {
const dx = x - 0.5
const dy = y - 0.5
const dist = Math.hypot(dx, dy)
const max = 0.5
const scaled = dist > max ? max / dist : 1
lightDir.value = {
x: clamp(0.5 + dx * scaled, 0, 1),
y: clamp(0.5 + dy * scaled, 0, 1)
}
}
return {
variantId,
variant,
bakedRender,
colorPresetId,
swatchId,
colorIntensity,
lightModeId,
lightIntensity,
lightDir,
lightMode,
activeNode,
outputFilter,
colorLayerStyle,
lightLayerStyle,
setLightFromUnit
}
}
export type HeroControls = ReturnType<typeof useHeroControls>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
import type { TranslationKey } from '../i18n/translations'
interface CustomerStory {
slug: string
image: string
category: TranslationKey
title: TranslationKey
body: TranslationKey
detailPrefix: string
readMoreHref?: string
}
export const customerStories: CustomerStory[] = [
{
slug: 'series-entertainment',
image:
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
category: 'customers.story.series-entertainment.category',
title: 'customers.story.series-entertainment.title',
body: 'customers.story.series-entertainment.body',
detailPrefix: 'customers.detail.series-entertainment',
readMoreHref:
'https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui'
},
{
slug: 'open-story-movement',
image:
'https://media.comfy.org/website/customers/open-story-movement/cover.webp',
category: 'customers.story.open-story-movement.category',
title: 'customers.story.open-story-movement.title',
body: 'customers.story.open-story-movement.body',
detailPrefix: 'customers.detail.open-story-movement',
readMoreHref: 'https://blog.comfy.org/p/how-open-source-is-fueling-the-open'
},
{
slug: 'moment-factory',
image:
'https://media.comfy.org/website/customers/moment-factory/cover.webp',
category: 'customers.story.moment-factory.category',
title: 'customers.story.moment-factory.title',
body: 'customers.story.moment-factory.body',
detailPrefix: 'customers.detail.moment-factory',
readMoreHref:
'https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping'
},
{
slug: 'ubisoft-chord',
image: 'https://media.comfy.org/website/customers/ubisoft/cover.webp',
category: 'customers.story.ubisoft-chord.category',
title: 'customers.story.ubisoft-chord.title',
body: 'customers.story.ubisoft-chord.body',
detailPrefix: 'customers.detail.ubisoft-chord',
readMoreHref:
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
},
{
slug: 'groove-jones',
image:
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
category: 'customers.story.groove-jones.category',
title: 'customers.story.groove-jones.title',
body: 'customers.story.groove-jones.body',
detailPrefix: 'customers.detail.groove-jones'
}
]
export function getStoryBySlug(slug: string): CustomerStory | undefined {
return customerStories.find((s) => s.slug === slug)
}
export function getNextStory(slug: string): CustomerStory {
const index = customerStories.findIndex((s) => s.slug === slug)
return customerStories[(index + 1) % customerStories.length]
}

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ const baseRoutes = {
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
@@ -36,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
}
@@ -79,22 +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',
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'

View File

@@ -1,17 +0,0 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { customerStorySchema } from './content/customers.schema'
const customers = defineCollection({
// Preserve the exact path as the id (default slugification lowercases the
// `zh-CN` locale folder, which would break locale filtering).
loader: glob({
base: './src/content/customers',
pattern: '**/*.mdx',
generateId: ({ entry }) => entry.replace(/\.mdx$/, '')
}),
schema: customerStorySchema
})
export const collections = { customers }

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