Compare commits

..

7 Commits

Author SHA1 Message Date
Alexander Brown
e54769400f refactor: derive badge rows on read, delete the badge store (#13686)
## Summary

Prototype: badge rows are derived presentation, not entity state, so
this replaces the materialized `nodeBadgeStore` (and its
registration/watcher/scope machinery, which amounted to cache
invalidation) with a per-node memoized derivation.

## Changes

- **What**: `nodeBadges(node)` — one lazy `computed` per node instance
in a `WeakMap`; entries die with their nodes, so registration, teardown,
and the stale-bucket bug class do not exist. A single
`graphStructureRevision` (litegraph leaf, bumped at
`add`/`remove`/`clear` chokepoints and structure events) replaces bucket
membership, `subgraphCreditsRevision`, and the
`resolveGraphId`/`resolveNode` seams. Litegraph consumes rows through a
one-function `setBadgeRowsProvider` seam, keeping its import graph
acyclic. `computeBadges` stays pure/plain-data; the identity-keyed draw
cache is unchanged. Net −456 lines.
- **Breaking**: none beyond the parent PR; `node.badges` and settings
behavior unchanged.

## Review Focus

- ECS-goal compliance despite deleting the store: authoritative truth
stays in the source stores + graph; badges were a projection, and the
design-doc amendment in `docs/architecture/node-badge-store.md`
("Prototype: derive-on-read") records the reasoning and trade-offs.
- Coarse invalidation: any structural change invalidates every badge
computed (flag only; recompute is lazy per read). Stress case is
large-workflow drag/paste.
- Legacy-canvas redraw on async price resolution still rides the
`pricingRevision` watch in `useNodeBadge`, same as the parent branch.

Child of #13460 — compare against `drjkl/and-then-there-were-none`, not
`feature/ecs-migration`.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:51:09 -07:00
Alexander Brown
eb80183618 feat: nodeBadgeStore + badge system (slice A) (#13458)
## Summary

Extracts node badges into a dedicated store per ADR 0008 — slice A of
`docs/architecture/node-badge-store.md`: store, plain `BadgeData` rows,
reactive badge system, and LGraph chokepoint registration. Stacked on
#13455.

## Changes

- **What**:
- `src/types/badgeData.ts` — plain `BadgeData { kind, text, fgColor?,
bgColor?, iconKey? }` rows (decision 1).
- `src/stores/nodeBadgeStore.ts` — root-scoped buckets keyed by
`NodeId`, kind-ordered reads, identity-checked row deletes,
`registerNode`/`unregisterNode`/`clearGraph` registration trio; row
writes refused for unregistered nodes (decision 2).
- `src/systems/badgeSystem.ts` — pure `computeBadges(sources)` +
effect-scope-per-node shell watching `registeredNodeIds`; writes core
and credits rows from
nodeDef/settings/palette/pricing/widget-value/link-connectivity sources
(decisions 3–4).
- `LGraph.add`/`remove`/`clear` + subgraph-definition GC maintain the
store registration (linkStore/rerouteStore convention).
  - Design record updated with implementation notes.
- **Breaking**: none yet — `node.badges`/`useNodeBadge` closures
untouched; nothing starts the system in production until slice B's
consumer cutover.

## Review Focus

- Registration is bucket-key presence; the system discovers nodes by
watching `registeredNodeIds`, so litegraph never imports pricing/nodeDef
modules (no import cycle via the litegraph barrel).
- Core rows are fine-grained (lifecycle, id, source) under one
visibility rule — unification notes in the design record (Vue gains
HideBuiltIn on id badges; legacy join/truncation becomes slice-B
draw-cache presentation).
- Subgraph credits aggregation, the legacy `node.badges` surface, and
`badgePosition` are open decisions in the design record and deliberately
not in this PR.
- Slice A+B together would exceed the ~300-line guideline, hence the
split; store row APIs (`registerBadge`/`deleteBadge`) land now because
the slice-B shim consumes them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:56:31 -07:00
Alexander Brown
0d4df04cf3 refactor: read widgeted-input connectivity from linkStore (#13455)
## Summary

Migrates the `inputs[].link` mirror-based reactivity tracking to
`linkStore` queries and deletes the slot-link reprojection machinery in
`useGraphNodeManager`.

## Changes

- **What**: `NodeSlots.linkedWidgetedInputs`, `usePartitionedBadges`'
pricing pokes, and `trackNodePrice` now query
`linkStore.isInputSlotConnected` (root graph id + unfiltered slot
index). Deletes the `node:slot-links:changed` handler and its
load-bearing comment, the emitter-less `node:slot-errors:changed`
handler, `refreshNodeInputs`, and the node-removal refresh-all loop.
Adds unified-mode NodeSlots tests (none existed) and a `trackNodePrice`
reactivity test. Drafts `docs/architecture/node-data-store.md` for the
next phase.
- **Not removed**: the `node:slot-links:changed` emitters and trigger
type entries, the `input.link` mirror itself, the computation-path
`inp.link` reads in `useNodePricing.buildJsonataContext` (migrated in
#13498 alongside the mirror deletion), and the `node:slot-label:changed`
handler — those belong to the Slot extraction / nodeDataStore phases.

## Review Focus

- The node-removal refresh-all loop deletion (own commit) — originally
shipped as a regression fix; gated locally on the vue-nodes legacy
widget e2e spec plus the full vueNodes suite (126 passed).
- Pricing badges previously only re-evaluated for widgeted inputs (the
trigger's gate); linkStore queries now also cover non-widgeted pricing
inputs — a correctness improvement, not parity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:27:05 -07:00
Alexander Brown
368b5bedd4 ECS: derive slot floating links; reroute cleanup follow-ups (#13451)
## Summary

Follow-up cleanups from the reroute chain store work (#13449): deletes
the `slot._floatingLinks` mirror in favor of endpoint derivation, fixes
two position/endpoint integrity gaps the mirror had masked, and
restructures the subgraph-unpack chain stitching.

Stacked on #13449.

## Changes

- **What**:
- `slot._floatingLinks` Sets deleted — a floating link's attachment is
fully encoded in its own endpoints, so `slotFloatingLinks(network, side,
nodeId, slot)` derives it; ~25 write sites across
`addFloatingLink`/`removeFloatingLink`, `setFloatingLinkOrigin`, and
every `FloatingRenderLink` connect method are gone
- `moveInputLink`/`moveOutputLink` gain the `node` param their
`dragNewFrom*` siblings already take
- `FloatingRenderLink.connectToSubgraphOutput` now writes the link's
**target** end (it wrote `origin_id = SUBGRAPH_OUTPUT_ID`,
clobbering/skewing endpoints — masked by the mirror)
- `removeInput`/`removeOutput` renumber floating-link slot indices
alongside real links (stale indices previously persisted into serialized
floating links)
- `Reroute.snapToGrid` mirrors into the layout store like `move()` does
(Vue hit-testing read a stale spatial index after snapped drags)
- Subgraph-unpack reroute chain stitching rewritten as
collect-then-stitch (two segment walks + one generic pointer pass,
replacing three interleaved walk-and-write loops)

## Review Focus

- Scoped out with written assessments: `input.link`/`output.links`
mirror removal (roadmap-scale `SlotConnection` extraction, ~530
accesses) and reroute position ownership inversion (layout store only
seeds the active graph, no writeback)
- Corrupt-data error paths in unpack stitching are normalized to
per-link skip (the old code broke the whole loop on some
internal-segment failures)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:04:19 -07:00
Alexander Brown
3e458f5e6f ECS: Reroute chain store with derived link membership (#13449)
## Summary

Extracts reroute connectivity into a dedicated `rerouteStore` per ADR
0008: chain state (`parentId`, floating marker) is stored by reference,
and link membership (`linkIds`/`floatingLinkIds`) is derived from the
links' `parentId` chains instead of being hand-maintained at ~10 write
sites.

Stacked on #13436 (link topology store); design record in
`docs/architecture/reroute-chain-store.md`.

## Changes

- **What**:
- `LLink._state` now holds the link store's reactive proxy (BaseWidget
pattern), so `link.parentId` writes are tracked by Vue
- New `rerouteStore`: root-scoped buckets keyed by `RerouteId`,
`RerouteChain {id, parentId?, floating?}` registered by reference;
`Reroute` class reads/writes through the store proxy
- Membership derived via a per-graph computed reverse index over link
topologies + chain states; `Reroute.linkIds`/`floatingLinkIds` become
readonly derived accessors
- Deleted: all manual membership mutation sites,
`Reroute.validateLinks`, `Reroute.removeLink`, `Reroute.update`'s
linkIds param
- `LGraph._addReroute`/`_removeReroute` chokepoints mirror
`_addLink`/`_removeLink`
- Load-time reroute-id dedup across sibling subgraph definitions
(mirrors node-id dedup; reroute ids must now be per-root-unique)
- Serialization emits `linkIds` in ascending link-id order; goldens
added for byte-identical round-trip
- **Breaking**: `Reroute.linkIds`/`floatingLinkIds` are now
`ReadonlySet` accessors — external mutation of these sets no longer
affects membership (the chain is the single source of truth).
`Reroute.validateLinks`/`removeLink` removed.

## Review Focus

- `LLink.disconnect` now unregisters the link before pruning empty
reroutes, so derived counts exclude it
- `dropOnReroute` resolves the drop target's source output once before
connecting moved links — re-anchoring the first link's chain changes
derived membership immediately (`LinkConnector.ts`)
- Workflows whose stored `linkIds` contradict their chains are repaired
rather than preserved (design decision 5; no shim)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 12:35:12 -07:00
Alexander Brown
9c7af27a52 ECS: Link topology store (single source of truth for link data) (#13436)
## Summary

Make a Pinia store (`useLinkStore`) the single source of truth for graph
link topology (ADR-0008 / ECS), mirroring `widgetValueStore`. `LLink`
topology fields become accessors over one `_state` object registered
into the store **by reference** — one copy, no mirror, no sync bridge.

## Changes

- **What**:
- New `useLinkStore` (`src/stores/linkStore.ts`) — **keyed by target
input slot**. At most one live link can target a given input slot
(litegraph disconnects the previous link before connecting a new one),
and every consumer queries by target, so the dominant query ("is this
input connected, and by what?") is one map lookup. Public surface is 6
actions (`registerLink`, `updateEndpoint`, `deleteLink`,
`isInputSlotConnected`, `getInputSlotLink`, `clearGraph`).
- Links without a unique target live in a per-graph side collection:
floating links (either endpoint unassigned; several can share an input
slot) and links into `SUBGRAPH_OUTPUT_ID` (a constant shared by every
subgraph in a root bucket). Neither is queried by target. Floating links
never answer `isInputSlotConnected`, matching `input.link` semantics.
- `LLink` topology backed by a single `_state: LinkTopology`; endpoint
setters delegate to `updateEndpoint` (displace → patch → re-place) when
registered. `updateEndpoint`/`deleteLink` take the topology by reference
and are identity-checked; `LLink._graphId` is set only while the link
actually holds a registration.
- `LGraph._addLink` / `_removeLink` chokepoints: every `_links` map
add/remove funnels through them (both `configure` loops, subgraph
reconnect, duplicate-link purge, `linkFixer`), keeping link-store and
layout entries in sync. Subgraph-definition removal and
non-root/unconfigured `clear()` unregister their links.
- Topology registered at every litegraph create site (`connectSlots`,
both `configure` loops, `addFloatingLink`, subgraph connect) and cleaned
up on `disconnect` / `removeFloatingLink` / `clear` / node-delete
cascade.
- Deleted the `layoutStore.ylinks` connectivity mirror +
`layoutMutations.createLink`/`deleteLink` +
`Create/DeleteLinkOperation`; node removal disconnects links before
`onNodeRemoved` fires, so layout cleanup needs no connectivity mirror or
layout→link cross-store call.
- Migrated the widget-disabled hot path (`WidgetItem.isLinked`,
`AppModeWidgetList`, `buildSlotMetadata`) to O(1) `isInputSlotConnected`
/ `getInputSlotLink`; removed the now-redundant `node:slot-links:changed
→ refreshNodeInputs` re-projection.
- Serialization round-trip goldens (plain / reroute-chain / floating /
subgraph). Because the store no longer keys by link id, subgraph link-id
collisions are irrelevant to it and **workflows load without any link-id
rewriting** (the node-id dedup for widget-store keys is pre-existing and
unchanged).
- **Breaking**: `LLink` topology fields (`origin_id`, `origin_slot`,
`target_id`, `target_slot`, `type`, `parentId`) are now accessors over
`_state`; `parentId` moves from own-property to prototype accessor
(`hasOwnProperty('parentId')` now returns `false`). `asSerialisable()`
output is byte-identical (guarded by goldens).

## Review Focus

- **Register-by-reference**: `LLink._state` is the exact object the
store holds — no second copy, no sync function. Verify no path writes
topology to two places.
- **Target-keying invariant**: one live link per input slot is enforced
by litegraph (`connectSlots` disconnects first); the store's side
collection covers exactly the links that violate or escape it (floating,
subgraph-output-node targets).
- Endpoint setters must delegate fully to `updateEndpoint` when
registered and mutate `_state` directly only when unregistered.
- Lifecycle completeness: every create path registers, every destroy
path unregisters — `_addLink`/`_removeLink` are the chokepoints.
- **Reroutes explicitly deferred.** Remaining `input.link` connectedness
*reads* (`nodeDataUtils`, `useNodePricing`, GLSL preview) are strangler
residue for a follow-up — not in scope here.

Stacked on #13322 — base is `drjkl/safe-widget-cleanup`.

## Migration note (extension authors)

`LLink` topology fields (`id`, `origin_id`, `origin_slot`, `target_id`,
`target_slot`, `type`, `parentId`) are now accessors over an internal
`_state` object, so they are non-enumerable on live instances.
`Object.keys(link)`, `JSON.stringify(link)`, `{...link}`, and
`structuredClone(link)` no longer expose them. To copy or serialize a
link, use `link.asSerialisable()` — direct field reads
(`link.origin_id`) are unchanged. Persisted workflow JSON is unaffected
(byte-identical, covered by goldens).

## Screenshots

Manual render-parity checklist (run against a ~200-node workflow): links
render; connect/disconnect updates live; undo/redo; subgraph nav. E2E
`nodeSelection.spec.ts` passes 13/13 including "a linked widget is
disabled". Note: 8 `linkInteraction.spec.ts` screenshot diffs (~0.01
ratio) observed — no commit in this branch touches link
geometry/coordinates/draw order, so these are
pre-existing/environmental; confirm against base before un-drafting.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 21:21:58 -07:00
Alexander Brown
14d55eb305 ECS: Moving widgets out of the Node data (#13322)
## Summary

Move Vue widget render-only metadata out of the graph node manager and
into dedicated widget render state alongside widget values.

## Changes

- **What**: Adds `WidgetRenderState` in `widgetValueStore`, removes
`SafeWidgetData` from `useGraphNodeManager`, and has Vue widget
rendering compose from raw LiteGraph widgets plus store-backed
value/render state.
- **What**: Updates promoted widget, app mode, parameter panel, preview,
tooltip, and widget tests for the new render-state boundary.
- **What**: Hardens related browser tests against branded id typing and
brittle default workflow node ids.

## Review Focus

Please look closely at the ownership boundary between raw LiteGraph
widgets, `WidgetState`, and `WidgetRenderState`, especially promoted
widget metadata and linked-widget rendering.

## Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`
- focused widget/node unit tests
- focused Playwright check for `Should display added widgets`

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 10:42:30 -07:00
683 changed files with 17528 additions and 40684 deletions

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

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }}
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

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

@@ -6,7 +6,6 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -18,47 +17,13 @@ jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA already verified before merge queue
if: github.event_name == 'merge_group'
run: echo "CLA is checked on the pull request before it enters merge queue."
# The CLA action normally requires every commit author in a PR to sign.
# We only want the PR author to sign, so we allowlist all other committers
# by computing them from the PR's commits and excluding the PR author.
- name: Build author-only allowlist
id: allowlist
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -74,10 +39,9 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: ${{ steps.allowlist.outputs.allowlist }}
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -278,49 +278,32 @@ jobs:
continue
fi
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +384,6 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -437,37 +416,19 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -483,10 +444,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

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

@@ -23,10 +23,6 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -53,15 +49,12 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -110,7 +103,6 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -10,13 +10,11 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
])
)
)
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

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

@@ -1,51 +0,0 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud - supported by default'
'Custom-node packs on Comfy Cloud supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 6 model cards are visible', async ({
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(6)
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop - Run AI on Your Hardware'
'Download Comfy Desktop Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

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

View File

@@ -1,112 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expect(page).toHaveTitle('Payment Successful Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expect(page).toHaveTitle('Payment Failed Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expect(page).toHaveTitle('支付成功 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expect(page).toHaveTitle('支付失败 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

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: 60 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: 89 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: 96 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:",

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,4 +0,0 @@
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

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

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive, watch } from 'vue'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
@@ -65,7 +40,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in parsedFaqs"
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -108,23 +83,8 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
@@ -20,7 +20,6 @@ type RowMedia =
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
export interface FeatureRow {
@@ -59,7 +58,7 @@ const {
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
@@ -73,11 +72,10 @@ const {
</div>
<!-- Media: image or video -->
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
<div
:class="
cn(
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
@@ -88,12 +86,7 @@ const {
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
:class="
cn(
'absolute inset-0 size-full rounded-4xl',
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
)
"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
@@ -106,13 +99,7 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
:fit="row.media.fit"
:class="
cn(
'absolute inset-0 size-full',
row.media.fit === 'contain' && 'bg-transparent'
)
"
class="w-full"
/>
</div>
</GlassCard>

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,20 +0,0 @@
<script setup lang="ts">
const { resources } = defineProps<{
resources: { label: string; href: string; display: string }[]
}>()
</script>
<template>
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
<li v-for="resource in resources" :key="resource.href">
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
<span class="text-primary-warm-gray"> </span>
<a
:href="resource.href"
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
>
{{ resource.display }}
</a>
</li>
</ul>
</template>

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

@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,7 +8,6 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -97,8 +96,7 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
import { t } from '../../../i18n/translations'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

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

@@ -85,9 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}
@@ -176,7 +174,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
</div>
</footer>
</template>

View File

@@ -10,7 +10,6 @@ import {
whenever
} from '@vueuse/core'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import type { HTMLAttributes } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
@@ -31,9 +30,7 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false,
fit = 'cover',
class: className
hideControls = false
} = defineProps<{
locale?: Locale
src?: string
@@ -43,8 +40,6 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -194,12 +189,7 @@ function toggleFullscreen() {
<template>
<div
ref="playerEl"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
className
)
"
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
@pointermove="showControls"
@pointerdown="showControls"
@focusin="showControls"
@@ -207,9 +197,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
class="size-full object-cover"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

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

@@ -1,16 +0,0 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,25 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
const { title } = defineProps<{ title: string }>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
{{ title }}
</h1>
</section>

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

@@ -6,15 +6,14 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -20,8 +20,6 @@ export const buttonVariants = cva(
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
inline:
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

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

@@ -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,14 +15,12 @@ const baseRoutes = {
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp',
brand: '/brand'
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -37,37 +35,19 @@ type Routes = typeof baseRoutes
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
//
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
// Customer Agreement template), same reasoning. See the comment header
// in src/pages/enterprise-msa.astro.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService',
'enterpriseMsa'
'termsOfService'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -80,23 +60,18 @@ export const externalLinks = {
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -1,7 +1,5 @@
import type { LocalizedText } from '../i18n/translations'
import { BRAND_ASSETS_ZIP } from './brandAssets'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',

View File

@@ -1,9 +0,0 @@
// Shared brand download URLs served from the media bucket, used by both the
// affiliate page and the brand portal.
export const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
export const BRAND_GUIDELINES_PDF =
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'

View File

@@ -1,91 +0,0 @@
interface BrandColor {
name: string
hex: string
rgb: string
hsl: string
cmyk: string
swatchClass: string
textClass: string
wide?: boolean
border?: boolean
}
export const brandColors: readonly BrandColor[] = [
{
name: 'Comfy Yellow',
hex: '#F2FF59',
rgb: '242, 255, 89',
hsl: '65, 100, 67',
cmyk: '5, 0, 65, 0',
swatchClass: 'bg-primary-comfy-yellow',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Comfy Ink',
hex: '#211927',
rgb: '33, 25, 39',
hsl: '274, 22, 13',
cmyk: '15, 36, 0, 85',
swatchClass: 'bg-primary-comfy-ink',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Comfy Canvas',
hex: '#C2BFB9',
rgb: '194, 191, 185',
hsl: '40, 7, 74',
cmyk: '0, 2, 5, 24',
swatchClass: 'bg-primary-comfy-canvas',
textClass: 'text-primary-comfy-ink'
},
{
name: 'Comfy Plum',
hex: '#49378B',
rgb: '73, 55, 139',
hsl: '253, 43, 38',
cmyk: '47, 60, 0, 45',
swatchClass: 'bg-primary-comfy-plum',
textClass: 'text-primary-comfy-canvas'
},
{
name: 'Warm White',
hex: '#F0EFED',
rgb: '240, 239, 237',
hsl: '40, 9, 94',
cmyk: '0, 0, 1, 6',
swatchClass: 'bg-primary-warm-white',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Warm Gray',
hex: '#7E7C78',
rgb: '126, 124, 120',
hsl: '40, 2, 48',
cmyk: '0, 2, 5, 51',
swatchClass: 'bg-primary-warm-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Cool Gray',
hex: '#3C3C3C',
rgb: '60, 60, 60',
hsl: '0, 0, 24',
cmyk: '0, 0, 0, 76',
swatchClass: 'bg-secondary-cool-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Mauve',
hex: '#4D3762',
rgb: '77, 55, 98',
hsl: '271, 28, 30',
cmyk: '21, 44, 0, 62',
swatchClass: 'bg-secondary-mauve',
textClass: 'text-primary-warm-white'
}
] as const

View File

@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -30,23 +30,15 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -103,7 +95,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,8 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -25,10 +14,6 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -37,54 +22,43 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
---
<!doctype html>
@@ -126,7 +100,10 @@ const structuredData = noindex
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
{structuredData && <JsonLdGraph graph={structuredData} />}
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
@@ -146,25 +123,7 @@ const structuredData = noindex
)}
<ClientRouter />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
<slot name="head" />
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -178,16 +137,8 @@ const structuredData = noindex
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main>
<main class="mt-20 lg:mt-32">
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 - Page Not Found - Comfy"
title="404 Page Not Found Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -1,54 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../config/routes'
const routes = getRoutes('en')
const resources = [
{
label: 'Learning Center',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: 'Workflow templates',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: 'Customer stories',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: 'Docs',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="You're booked - Comfy"
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
noindex
>
<HeroSection title="You're booked." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>Check your email for the calendar invite and meeting link!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
Resources while you wait
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -1,28 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import BrandBackground from '../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('brand.page.title', locale)}
description={t('brand.page.description', locale)}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection />
<BrandLogosSection />
<BrandColorSection client:visible />
<BrandVoiceSection />
<BrandTrademarkSection />
<BrandQuestionsSection />
</div>
</BaseLayout>

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers - Comfy"
title="Careers Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies - Comfy">
<BaseLayout title="Case Studies Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - AI in the Cloud"
title="Comfy Cloud AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -2,41 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="Pricing — Comfy Cloud">
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,25 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="Contact — Comfy">
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories - Comfy">
<BaseLayout title="Customer Stories Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,55 +19,94 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -8,30 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop - Run AI on Your Hardware"
title="Download Comfy Desktop Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -1,36 +0,0 @@
---
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
// Legal-reviewed copy must not be served under a localized route until legal
// explicitly approves a translation; rendering an unreviewed translation as
// the active MSA exposes us to liability from the translation diverging from
// the approved English source. See the matching comment in
// src/i18n/translations.ts for the i18n block, and the entry in
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/legal/HeroSection.vue'
import LegalContentSection from '../components/legal/LegalContentSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('enterprise-msa.page.title')}
description={t('enterprise-msa.page.description')}
>
<HeroSection title={t('enterprise-msa.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('enterprise-msa.page.effectiveDateLabel')}: {
t('enterprise-msa.effective-date')
}
</p>
<p
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
>
{t('enterprise-msa.page.parties')}
</p>
<LegalContentSection
prefix="enterprise-msa"
locale="en"
tocLabelKey="enterprise-msa.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery - Comfy">
<BaseLayout title="Gallery Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy - Professional Control of Visual AI"
title="Comfy Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

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