Compare commits

..

51 Commits

Author SHA1 Message Date
Matt Miller
414ef7fbae Merge remote-tracking branch 'origin/main' into mattmillerai/fix-customer-create-409
# Conflicts:
#	src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts
2026-07-01 21:13:27 -07:00
Matt Miller
8c49e64682 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-30 21:04:00 -07:00
Matt Miller
fbca7ba220 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-30 13:18:51 -07:00
Matt Miller
13965f44bf fix: drop duplicate launches nav entry from merge 2026-06-30 06:11:35 -07:00
Matt Miller
59b7e118c8 Merge remote-tracking branch 'origin/main' into mattmillerai/fix-customer-create-409
# Conflicts:
#	apps/website/src/data/drops.ts
#	browser_tests/fixtures/utils/objectInfo.ts
#	browser_tests/fixtures/utils/promotedMissingModel.ts
#	package.json
#	src/components/common/DropdownItem.vue
#	src/components/common/FilterDropdown.vue
#	src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
#	src/components/rightSidePanel/parameters/SectionWidgets.test.ts
#	src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
#	src/composables/graph/useErrorClearingHooks.ts
#	src/composables/useEssentialTileNodeDef.ts
#	src/core/graph/subgraph/promotedWidgetTypes.ts
#	src/core/graph/subgraph/resolveConcretePromotedWidget.ts
#	src/locales/he/main.json
#	src/locales/he/nodeDefs.json
#	src/platform/cloud/subscription/composables/useSubscription.ts
#	src/platform/cloud/subscription/utils/creditsProgress.ts
#	src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts
#	src/platform/missingModel/missingModelScan.test.ts
#	src/platform/missingModel/types.ts
#	src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
#	src/services/litegraphService.ts
#	src/stores/authStore.test.ts
#	src/stores/authStore.ts
2026-06-30 06:06:09 -07:00
Matt Miller
9b527a275b test: use function declarations for fetchWithCustomerRecovery helpers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:50:50 -07:00
Matt Miller
ab9f66efc9 test: cover uid-scoped recovery memo and root-anchored customer endpoint
Regression tests for the two review fixes:
- fetchWithCustomerRecovery re-provisions when the active uid changes
  without an auth-state reset (uid-guarded memo).
- a 409 from a path where /customers/ is not the root segment
  (/foo/customers/bar) does not trigger provisioning.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407065
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407069
2026-06-26 15:56:18 -07:00
Matt Miller
e127c6e3a5 fix: anchor isCustomerEndpoint to the path root
pathname.includes('/customers/') also matched paths where /customers/ is
not the root segment (e.g. /foo/customers/x). Anchor with startsWith so
only true /customers/* endpoints trigger 409 recovery.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407069
2026-06-26 15:52:45 -07:00
Matt Miller
080293a078 fix: scope recoverMissingCustomer memo to the current uid
recoverMissingCustomer memoized customerRecovery with no uid guard,
relying solely on the onAuthStateChanged listener nulling it between
accounts. A re-auth that only fires onIdTokenChanged (uid changes, no
listener reset) would reuse the prior account's resolved recovery and
skip provisioning for the new account. Mirror the uid guard createCustomer
already applies to its customerCreated write: capture the uid alongside the
memo and start a fresh recovery when it no longer matches the current user.
Same-uid behavior is unchanged (no extra POST on a resolved memo).

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12751#discussion_r3478407065
2026-06-26 15:51:54 -07:00
Matt Miller
7df9b8b42a Merge branch 'main' into mattmillerai/fix-customer-create-409
Resolve conflicts where main introduced fetchWithUnifiedRemint (401 token
re-mint retry) at the same /customers/* call sites this PR routes through
fetchWithCustomerRecovery (409 missing-customer self-heal):

- fetchWithCustomerRecovery now composes both layers: its initial and retry
  fetches go through fetchWithUnifiedRemint, so customer endpoints keep main's
  401 re-mint behavior while gaining 409 self-heal. Gate matches other call
  sites (isCloud && flags.unifiedCloudAuthEnabled); flag-OFF degrades to a
  single plain fetch, identical to pre-merge behavior.
- All /customers/* call sites (authStore balance/credit/billing, subscription
  status/checkout) call fetchWithCustomerRecovery instead of the raw
  fetchWithUnifiedRemint; dropped now-unused fetchWithUnifiedRemint/
  useFeatureFlags imports in the subscription files.
- createCustomer keeps main's optional payload param and this PR's
  sessionUserId snapshot + uid guard.
2026-06-26 15:49:17 -07:00
Christian Byrne
63bda483b9 refactor: localize system stats headers and fix PyTorch casing (#12253)
## Summary
Localizes the 12 system stats column headers via vue-i18n and fixes the
`Pytorch` → `PyTorch` casing typo.

## Changes
- **src/locales/en/main.json**: Add 11 i18n keys under `g.systemStats*`
namespace
- **src/components/common/SystemStatsPanel.vue**: 
  - Import `useI18n` and use `t()` for column headers
  - Change `header` field to `headerKey` in ColumnDef type
  - Fix PyTorch casing (was `Pytorch Version`)

## Context
Follow-up to PR #11816 per review comment.

## Test plan
- [x] Column headers render correctly
- [x] Copy System Info includes localized headers
- [ ] Verify other locales can override (out of scope - EN only for now)

Closes #11870

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12253-refactor-localize-system-stats-headers-and-fix-PyTorch-casing-3606d73d36508134af99f7ca4f9c6593)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-26 15:30:28 -07:00
Yosef Chai
086b72f709 feat(i18n): add Hebrew (he) language support (#12721)
## Summary

Adds full Hebrew (`he` / עברית) localization for the ComfyUI frontend
UI, registered the same additive way as the existing RTL locales (`ar`,
`fa`).

## Changes

- **What**:
- New `src/locales/he/main.json`, `commands.json`, and `settings.json` —
complete, human-reviewed Hebrew translations.
- Exact key parity with `en` (3524 / 125 / 235 keys), verified
programmatically.
- All `{interpolation}` placeholders and `|`-separated plural forms are
preserved (same segment counts as `en`).
- Established technical terms are kept in English (API, GPU, VAE, CLIP,
LoRA, ControlNet, Civitai, Hugging Face, flux, Nodes 2.0, …).
- `dataTypes` and `nodeCategories` are kept as verbatim English
identifiers; option **keys** in
`settings.json`/`menuLabels`/`contextMenu` are left untouched (only
their display values are translated).
- `src/locales/he/nodeDefs.json` is an empty object on purpose, so node
definitions fall back to English and get auto-populated by the release
i18n workflow (per the locale CONTRIBUTING guide).
- Registered `he: { text: 'עברית', loaders: loadersFor('he') }` in
`src/locales/localeConfig.ts` (which automatically adds it to the
Settings → Language dropdown and `SUPPORTED_LOCALE_OPTIONS`).
- Added `he` to `outputLocales` in `.i18nrc.cjs`, plus a Hebrew glossary
block for the CI translator.

## Review Focus

- This follows the approach of #7876 (Persian/Farsi). Like the existing
`ar`/`fa` locales, it translates UI text only and does **not** introduce
RTL layout (`dir="rtl"`) — the app does not currently apply RTL layout
for any locale. I'm happy to follow up with proper RTL layout support in
a separate PR if that's wanted.
- Recurring-term glossary used for consistency: node = צומת, workflow =
תהליך עבודה, queue = תור, widget = פקד, subgraph = תת-גרף, canvas =
קנבס, bypass = עקיפה, prompt = פרומפט.
- Native-speaker review is very welcome. cc translation maintainers
@Yorha4D @KarryCharon @DorotaLuna @shinshin86

## Screenshots

Text-only locale addition — no UI/layout changes. After this change,
**Settings → Language** lists **"עברית"**, and selecting it renders the
UI in Hebrew (untranslated node definitions fall back to English).

---------

Co-authored-by: Yosef Chai <192742853+yosef-chai@users.noreply.github.com>
Co-authored-by: christian-byrne <abolkonsky.rem@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:58:18 +00:00
balpreetgrowthnatives
bb637bddc9 fix: prevent payment routes from being indexed (#12706)
## Summary

Allow crawler access to `/payment/` routes in robots.txt so search
engines can read the `noindex` tag, and forcefully inject the
`x-robots-tag: noindex` header via `vercel.json`.

## Changes

- **What**: Removed `Disallow: /payment/` from `robots.txt` and added
rules to `vercel.json` applying `x-robots-tag: noindex` to
`/payment/(.*)` and `/zh-CN/payment/(.*)` routes.

## Review Focus

- The configurations in `vercel.json` apply to both English and
localized payment routes (`/zh-CN/payment/(.*)`).

---------

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 15:11:12 -07:00
Christian Byrne
b86a418d16 fix: give backport push a token that can update workflow files (#13038)
## Summary

The `PR Backport` workflow silently fails for any PR that also modifies
a file under `.github/workflows/**`.

## Root cause

The `backport` job checks out with the default `GITHUB_TOKEN` and reuses
those persisted credentials for `git push`. GitHub refuses to let that
token create or update workflow files:

```
! [remote rejected]  backport-12804-to-core-1.45 -> backport-12804-to-core-1.45
  (refusing to allow a GitHub App to create or update workflow
   `.github/workflows/ci-tests-e2e.yaml` without `workflows` permission)
error: failed to push some refs
```

The cherry-pick itself succeeds — only the push is rejected. And because
the `run:` step inherits `set -e`, the loop aborts before writing the
`failed=` output, so the "Comment on failures" step (`if: failure() &&
steps.backport.outputs.failed`) posts nothing. The result is a red job
with no explanation on the PR.

## History

PR #12804 touched `.github/workflows/ci-tests-e2e.yaml` and
`.github/actions/setup-frontend/action.yaml`. Its backport run
([27788259837](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27788259837/job/82230406910))
failed exactly this way: cherry-pick clean on every target, push
rejected on the workflow file. All four backports (#12966, #12967,
#12968, #12969) had to be created manually.

## Changes

Check out with `PR_GH_TOKEN` (already used by the Create-PR step) so the
push carries `workflow` scope.

> [!IMPORTANT]
> `PR_GH_TOKEN` must have **workflow** write permission for this to take
effect. If it does not, the secret needs that scope added.

## Follow-up (not in this PR)

The push failure aborts the whole job under `set -e` with no PR comment.
Even with the token fixed, a push rejected for another reason (branch
protection, etc.) would still fail silently. Wrapping the push so a
single-target failure is recorded as a `push-failed` reason and reported
via the existing failure-comment step would make the workflow degrade
gracefully.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 20:37:02 +00:00
jaeone94
7ab1c72180 Redesign missing model detection contract for promoted subgraph widgets (#13059)
## Summary

Redesign missing-model detection for ADR 0009 promoted subgraph widgets
so candidates are created from the widget value the user can actually
edit, while still using the concrete interior widget as the
schema/options source.

## Why This PR Exists

This PR comes from the follow-up missing-model detection work for the
ADR 0009 / 1.46 subgraph widget changes introduced by
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197).

[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197)
intentionally changed promoted subgraph widgets to be represented
through subgraph input links. After that change, the promoted widget on
the host `SubgraphNode` is the editable value owner, and linked interior
widgets are no longer guaranteed to mirror that value.

The old missing-model contract still treated the concrete interior node
widget as the effective source of truth in subgraph cases:

- recursive scans entered the subgraph and scanned the interior widget
value;
- candidates were keyed by the interior node/widget identity;
- the parent subgraph host mostly received propagated
highlight/navigation behavior;
- subgraph container widgets were not treated as first-class candidate
sources.

That contract breaks after ADR 0009. A user can resolve a missing model
by changing the promoted host widget to an installed model, while the
linked interior widget can still hold the old stale value. If detection
keeps scanning the linked interior value, entering the subgraph or
reloading the workflow can re-create a false missing-model error that no
longer corresponds to the value the user can edit.

ADR 0009 also means the same subgraph definition can be reused by
multiple `SubgraphNode` hosts. Once missing-model detection moves from
the interior definition widget to the promoted host widget, the selected
value is no longer a property of the shared definition alone. It is a
property of a specific host instance. That makes the old interior-node
identity insufficient for mode changes, removal handling, and re-scan
behavior: a single interior leaf definition can be reachable through
multiple host execution paths, and only the affected host path should
add, remove, or restore a candidate.

This PR also builds on
[#12990](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12990),
which narrowed workflow-level `models[]` and embedded model data to
metadata enrichment only. Together, the intended boundary is:

- live widgets create missing-model candidates;
- workflow/root-level `models[]` and node metadata only enrich
candidates that already came from a live widget;
- promoted widget values are read from the editable host widget, not
inferred from stale interior `widgets_values`.

## Changes

- **What**:
  - Introduces promoted-widget scan targets that split:
    - host promoted widget value and candidate identity;
    - concrete leaf widget/node definition data.
- Scans the outermost unlinked promoted widget on a `SubgraphNode` host
as the selected value owner.
- Skips linked interior widgets as candidate sources, preventing stale
linked widget values from producing duplicate or false missing-model
candidates.
- Resolves the concrete leaf widget for combo options, asset-widget
support, node type, directory lookup, and embedded metadata enrichment.
- Keys promoted missing-model candidates by the host execution id and
host promoted widget name.
- Adds `sourceExecutionId` to promoted candidates so liveness still
follows the concrete source execution path, including nested inactive
subgraph containers.
- Uses source-scope activity for pipeline filtering and async
verification, while keeping highlight/store/clearing identity
host-keyed.
- Removes host-keyed promoted candidates when their source execution
scope is removed or bypassed.
- Re-scans ancestor subgraph hosts when an interior source path is
un-bypassed, so host-keyed promoted errors can reappear correctly.
- Handles shared subgraph definitions by deriving promoted source paths
from the concrete host instance path, rather than treating the shared
definition node id as globally unique.
- Shares promoted source resolution between Vue node processing and the
right-side panel to avoid drift.
- Aligns missing-model clearing across Vue node widgets, legacy canvas
widgets, and right-side panel Parameters/Nodes section widgets.
- Adds unit coverage for scan identity, source-scope liveness, dynamic
mode changes, source-scope removal, shared-definition host isolation,
and right-side panel clearing.
- Adds nested promoted-widget E2E coverage for OSS and Cloud flows
across Vue, Parameters tab, and legacy widget surfaces.
- **Breaking**: None expected.
- **Dependencies**: None.

## New Detection Contract

A missing-model candidate is created from an unlinked final editable
value owner.

That value owner can be:

- a normal node model widget; or
- the outermost promoted model widget displayed on a `SubgraphNode`
host.

For promoted widgets:

- the host promoted widget supplies the selected value;
- the host execution id and host widget name are the candidate identity;
- the concrete leaf widget supplies definition data such as combo
options and asset-browser support;
- the concrete source execution path is retained as `sourceExecutionId`
for liveness only;
- linked interior widgets are skipped as candidate sources because their
values are not authoritative when driven by a promoted input.

For a nested chain:

`Outer promoted widget A -> inner promoted widget B -> concrete widget
C`

only `A` creates the candidate. `B` and `C` are linked along the
promoted-input path and are skipped as selected-value sources, while `C`
still provides the concrete widget definition used to evaluate `A`.

## Shared Definition And Source-Scope Liveness

ADR 0009 promoted widgets make subgraphs behave more like reusable
definitions with host-owned inputs. Two host `SubgraphNode`s can point
at the same interior subgraph definition while carrying different
promoted widget values. In that shape, the missing-model candidate must
be keyed to the editable host surface, but the activity check cannot use
the host id alone.

For example, if two outer hosts share the same nested subgraph
definition, one host can select a valid model while the other still
selects a missing model. The result should be one missing-model
reference, not a single definition-level error and not two errors after
one host is fixed. Likewise, bypassing or un-bypassing an interior
nested container should affect only the host execution paths that
actually pass through that container.

This PR therefore separates two concepts:

- **candidate identity**: host execution id + host promoted widget name,
used for storage, highlight, navigation, and clearing;
- **candidate liveness**: concrete source execution path, used for
scan-time activity checks, pipeline filtering, async verification,
source-scope removal, and re-exposure after mode changes.

That separation is the reason this PR updates more than the scan itself.
Moving the detection target to the subgraph host also requires the
mode-change and removal paths to understand that a host-keyed candidate
can be invalidated by a descendant source path, and can need to be
restored by re-scanning an ancestor host when an interior source path
becomes active again.

## Review Focus

Please review the identity split carefully:

- candidate/store/highlight/clearing identity should remain host-keyed
for promoted widgets;
- liveness should use `sourceExecutionId` when present, falling back to
`nodeId` for normal candidates;
- scan-time activity checks should account for the source node itself
and all ancestor subgraph containers;
- source-scope removal should remove host-keyed candidates whose
concrete source path was removed or bypassed;
- un-bypassing an interior source path should re-scan affected ancestor
subgraph hosts so host-keyed candidates can reappear;
- shared subgraph definitions should not merge errors across different
host instances;
- linked interior widgets should not produce their own missing-model
candidates;
- asset-browser eligibility should be resolved from the concrete leaf
node type and widget name, not the synthetic subgraph host type;
- right-side panel edits should clear host missing-model errors and
source validation errors consistently.

The E2E matrix intentionally keeps nested promoted workflows only.
Nested promoted widgets cover the same editable host path as single
promoted widgets while also exercising the `A -> B -> C` chain that can
break source-scope liveness and re-scan behavior. The nested fixture
also includes multiple host instances that share the same subgraph
definition, so it verifies that fixing one host does not accidentally
clear or suppress another host's missing-model candidate. Direct/single
promoted behavior is still covered at the unit level.

## Non-Goals

- This PR does not reintroduce workflow-level `models[]` candidate
creation.
- This PR does not infer selected model values from `widgets_values`.
- This PR does not synchronize linked interior widget values back from
promoted host widgets.
- This PR does not redesign missing-media scanning; missing media still
skips subgraph containers and remains keyed by concrete interior paths.
The shared async post-verification active-scope filter is intentionally
stricter, so a pending missing-media candidate is no longer surfaced if
its own node is bypassed or removed while verification is in flight.

## Validation

- `pnpm exec vitest run
src/components/rightSidePanel/parameters/SectionWidgets.test.ts
src/platform/missingModel/missingModelScan.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/missingModelStore.test.ts
src/utils/graphTraversalUtil.test.ts
src/composables/graph/useGraphNodeManager.test.ts
src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
--reporter=dot`
  - 8 files passed, 294 tests passed.
- `pnpm exec vitest run
src/platform/missingModel/missingModelScan.test.ts
src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts
src/components/rightSidePanel/parameters/SectionWidgets.test.ts`
  - 3 files passed, 71 tests passed.
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm format:check`
- targeted ESLint for changed production/unit/E2E files
- `git diff --check`
- `pnpm build`
- `pnpm build:cloud`
- OSS affected E2E on the 8188 build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep "Changing an OSS .*promoted|Refreshing a
resolved promoted|Reloading a resolved nested"`
  - 5 passed.
- Cloud affected E2E on the 8188 cloud build:
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm
exec playwright test
browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
--project=cloud --grep "Changing a Cloud .*promoted"`
- 2 passed; the Cloud legacy promoted asset-modal case still fails until
[#13075](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13075) is
merged.
- Full OSS `errorsTabModeAware.spec.ts` on the 8188 build:
- 23 passed; 3 existing paste/clipboard cases failed before the promoted
subgraph section with node count remaining at 1 after
`clipboard.paste()`.
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`, and
browser typecheck where applicable.
- Pre-push hook ran `pnpm knip --cache`.

## Screenshots

Before


https://github.com/user-attachments/assets/6380c1da-1d92-4b70-888e-3ade572c4b5b

After


https://github.com/user-attachments/assets/4cfc24d6-3dc3-4e36-9b31-72fea6b3d9d5
2026-06-26 19:38:12 +00:00
imick-io
cd979432a9 feat(website): add /drops landing page (livestream hero + drops grid) (#13053)
## Summary

Adds the `/drops` landing page in English and Simplified Chinese, ahead
of the 2026-06-24 livestream. The page is composed of a livestream-gated
hero, a 10-card drops grid, a subscribe banner, and a closing CTA.
Production media for the hero and several cards is wired in; the rest
fall back to placeholders.

## Changes

- **New `/drops` page** (`pages/drops.astro`, `pages/zh-CN/drops.astro`)
plus en/zh-CN translations.
- **`HeroLivestream01` block** — renders the rotating-logo video before
the stream window, swaps to a YouTube nocookie iframe between
`startDateTime` and `endDateTime`. SSR stays deterministic on the logo
and only flips to the embed after client hydration. Takes
`youtubeVideoId` directly (no URL parsing).
- **Drops grid** — `data/drops.ts` defines the 10 cards with `imageFor`
/ `videoFor` media helpers, badges, and per-locale
title/description/href. `DropCard.vue` renders an image or autoplaying
muted video inside the new `Card` primitive set (`Card`, `CardHeader`,
`CardTitle`, `CardDescription`, `CardContent`, `CardFooter`).
- **Supporting blocks** — `SubscribeBanner` uses the Button `link`
variant with an animated hover underline; closing `CtaCenter01` is
extended with a terms link. `resolveRel` is shared from `utils/cta.ts`
instead of duplicated per block.
- **Visual extension to `HeroLivestream01.visual`** — discriminated
`image | video` union so the hero can render a looping video.
- **E2E** — `e2e/drops.spec.ts` covers both locales (hero + grid render,
locale-correct links).

## Review Focus

- `youtubeVideoId` is currently a `nlLZfNIqF8M` placeholder;
`startDateTime`/`endDateTime` are placeholders too — see TODO in
`HeroSection.vue`. These need to be swapped to the production stream +
window before launch.
- Several drop cards still point at placeholder destinations (e.g. Comfy
MCP at `/mcp`) — TODO noted in `drops.ts`.
- Drop card media is a mix of images and short MP4s served from
`media.comfy.org`. The videos autoplay muted/looped with
`preload="metadata"`.

## Test plan

- [ ] `pnpm --filter website dev` → visit `/drops` and `/zh-CN/drops`;
confirm hero video plays, grid renders all 10 cards, video cards loop,
subscribe banner link styled correctly
- [ ] Temporarily set `startDateTime` to the past and `endDateTime` to
the future; confirm hero swaps to the YouTube iframe after hydration
- [ ] `pnpm --filter website test:e2e drops.spec.ts`
- [ ] `pnpm typecheck:website`

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 19:09:04 +00:00
Alexis Rolland
e8c41158d7 ci: Update CLA allowlist for GitHub Actions (#13169)
## Summary

- Update CLA check `allowlist` to add `GitHub Action` ,
`action@github.com`, `Glary-Bot`
- Reorder list alphabetically
2026-06-26 11:19:31 -07:00
AustinMroz
19622ac3b9 Essentials tab redesign (#12744)
Subsumes #12304 

Redesigns the Essentials tab to be frontend designed with more
accessible icons and tighter organization.
<img width="381" height="1345" alt="image"
src="https://github.com/user-attachments/assets/193f7f5f-20c8-4bf0-8304-ec2c990186d0"
/>

---------

Co-authored-by: comfydesigner <comfydesigner@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 17:57:58 +00:00
Alexander Brown
41b64480c3 docs: retarget ECS architecture docs to dedicated stores over a single World (#13039)
## Summary

Realign the ECS architecture docs and ADR 0008 with the shipped
direction (PR #12617): entity data lives in dedicated Pinia stores keyed
by string IDs, rather than one unified \"World\" registry addressed by
branded entity IDs.

## Changes

- **What**: Docs-only. Retarget the `docs/architecture/` set + ADR 0008
+ agent guidance from the single-World/branded-`*EntityId` model to the
dedicated-store model that PR #12617 actually shipped
(`widgetValueStore` keyed by `WidgetId`, `layoutStore`,
`nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`,
`previewExposureStore`).
- `AGENTS.md` + `.agents/checks/adr-compliance.md`: point entity-data
guidance at dedicated stores; fix the inverted `world.getComponent`
compliance check (it flagged correct store-based code).
- `ADR 0008`: dated amendment note (stays Proposed); rewrite the World
section → dedicated stores, Branded ID design (`WidgetId` composite
string), migration strategy, render-loop, consequences, notes.
- `proto-ecs-stores.md`: flip the \"Unified World / branded IDs\"
framing from gap-to-close → target; replace the deleted
`PromotedWidgetViewManager` with the `input.widgetId` store-backed
model; fix key formats and store count.
- `ecs-target-architecture.md` / `ecs-lifecycle-scenarios.md` /
`ecs-migration-plan.md`: reframe all `world.*` APIs and `*EntityId`
brands to per-store APIs + string keys; mark already-shipped migration
phases done.
- `subgraph-boundaries-and-promotion.md` / `entity-interactions.md` /
`entity-problems.md`: scope-tagged store entries; swap removed
`PromotionStore` for `previewExposureStore`.
- `appendix-critical-analysis.md`: post-pivot status banner + resolution
notes on the critiques the pivot vindicated; still-open gaps (extension
callbacks, atomicity, Y.js↔ECS) left live.
- `appendix-ecs-pattern-survey.md`: supersede banner; keep the external
library survey (§1).
- Delete obsolete `ecs-world-command-api.md` (its command-pattern
argument folded into ADR 0008).
- **Breaking**: None (documentation only).

## Review Focus

- ADR 0008 stays **Proposed** with an amendment note rather than a new
superseding ADR — confirm that's the preferred mechanism vs. a fresh
ADR.
- Numeric per-kind brands (`NodeEntityId`, `LinkEntityId`, …) are
retained in ADR 0008 but explicitly marked aspirational/unshipped; only
`WidgetId` (composite string) reflects shipped code.
- `appendix-ecs-pattern-survey.md` §2–§4 are kept under a supersede
banner as historical record (they describe the deleted `src/world/`
substrate) rather than rewritten — confirm that's preferred over
deletion.
- Net −384 lines; no code or test changes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:36 +00:00
AustinMroz
5c0bafe998 Ensure dynamic combo children cleanup state (#13073)
#12617 introduced a regression in Dynamic Combos. If two options have
child widgets of the same name (such as `bit_depth` on `Save Image
(Advanced)`), then widget state would be incorrectly shared between the
two widgets.

This is resolved by having removed widgets also delete their state.

There was previous interest in having widgets of this type keep state
when valid. This interest remains, but will require a more controlled
intentional implementation in the future.

Since the bit depth options on `Save Image (Advanced)` could potentially
be expanded in the future, this PR specifically adds a new devtools node
for testing with.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 01:08:06 +00:00
Comfy Org PR Bot
26166d3128 1.47.5 (#13166)
Patch version increment to 1.47.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-26 00:51:57 +00:00
AustinMroz
94499c5cff When dragging vue nodes, also drag reroutes (#12885)
`selectedItems` was being filtered to nodes and groups. Since no special
behaviour is being performed on groups, the 'move groups' code is
relaxed to instead 'move all non-node selected items'.
2026-06-26 00:11:48 +00:00
Comfy Org PR Bot
99dd27cd8b 1.47.4 (#13083)
Patch version increment to 1.47.4

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 00:01:05 +00:00
Christian Byrne
97c59f3adb refactor(litegraph): delete dead onGetNodeMenuOptions, deprecate onBeforeChange (#12230)
Stacks on #12228. Part of the LGraph dead-hook cleanup per AUDIT-LG.9.

## What

- `LGraph.onGetNodeMenuOptions` — **deleted** (field + dispatcher in
`LGraphCanvas.getNodeMenuOptions`). Zero ecosystem consumers.
- `LGraph.onBeforeChange` — **deprecated, not deleted**. The field and
the dispatch in `LGraph.beforeChange()` are kept, but assigning a
handler now emits a one-time `warnDeprecated` nudging migration to
`LGraphCanvas.onBeforeChange`.

## Why onBeforeChange is preserved

The W2F-1 re-audit found `bmad4ever/ComfyUI-Bmad-DirtyUndoRedo` assigns
`app.graph.onBeforeChange = fn` (the listener-assignment pattern).
Deleting the field outright would silently turn those handlers into
no-ops. Keeping it as a deprecated shim preserves backward compatibility
during a grace period while signaling the intended replacement.

`onAfterChange` is untouched (so `BennyKok/comfyui-deploy`'s
`onAfterChange` wrapper keeps working). `LGraphCanvas.onBeforeChange`
remains a separate field, and the canvas dispatch chain
`this.canvasAction((c) => c.onBeforeChange?.(this))` is unchanged.

## Tests

`LGraph.test.ts` covers the shim: the assigned listener is still
invoked, the deprecation warning fires when used, and no warning fires
when no listener is assigned.

## Sequencing

- Stacks on #12228
- Sequences behind Alex's Phase B (#11939, #11811)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 16:50:20 -07:00
Dante
95522a7981 feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768) (#12761)
## Summary

Aligns the Settings ▸ Workspaces **Plan & Credits** panel to DES-186:
state-driven subtitle, team/personal header variants, design perks, and
a shared footer help bar. Stacked on FE-964 (#12734), which owns the
CreditsTile content.

## Changes

- **What**:
- Subtitle per design variants (Figma 3255-21472): Active → "Renews on
{date}", Ending → "Ends on {date}". `subscription_status: 'scheduled'`
falls back to the Active treatment — the facade exposes no
scheduled-plan target/date fields yet, so "Changes to {plan} on {date}"
cannot be rendered (template reserved in i18n-ready form; see Linear
note).
- Team-active header: plan name "Team" + seat-aware workspace total
(`seat_summary.total_cost_cents` from `/billing/plans`) as "$X USD /
mo"; the Next-month-invoice card reads the same computed so the two
can't disagree. Per-member tier price + "USD / mo / member" kept as the
plans-unresolved fallback.
- CTAs per design + designer annotation: "Manage billing" + "Change
plan" on a team plan, "Upgrade plan" on personal.
- Team perks (Figma 2993-14789): "Your plan includes everything in
**Pro**, plus:" (i18n-t, plan name emphasized) + invite members /
concurrent runs / shared credit pool / role-based permissions.
- Personal no-subscription variant (Figma 2993-14604): "Free · $0 USD /
mo" header + primary Subscribe CTA + "What's included:" with the
max-runtime perk → "10 min max runtime" (`subscription.maxDuration.free`
set to 10 min per DES 3253-16079).
- Footer help bar (Learn more / Partner Nodes pricing table / Message
support / Invoice history) extracted into `SubscriptionFooterLinks.vue`,
shared by workspace + legacy panels; new surface-specific key for
"Partner Nodes pricing table".
- **Breaking**: none.

## Review Focus

- Seat-aware price source (`currentPlan.seat_summary.total_cost_cents`)
vs per-member fallback — fixture locks $320 vs $80.
- `'scheduled'` → Active fallback (adapter-level test in
`useWorkspaceBilling.test.ts`).
- Free-state perk copy: `subscription.maxDuration.free` set to **10
min** per DES 3253-16079 (design confirmed; was 30 min).
- Free-state overflow (`⋯`) button intentionally omitted: the only
existing menu item (Cancel Subscription) doesn't apply to a free plan.

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Plan & Credits half; Members invite UI ships in #12759)

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session). Team and free states use client-side API stubs (XHR-level for
`/api/billing/*` + `/api/workspace/*`, fetch-level for legacy
`/customers/*`) since the test workspaces are unsubscribed;
personal-active rows are real account data.

| State | Before (FE-964 base) | After (this PR) |
|---|---|---|
| Team plan — active | <img width="400" alt="before: Pro, $100
USD/mo/member, Renews, Manage Payment/Upgrade Plan"
src="https://github.com/user-attachments/assets/622a9a27-1875-4c08-92b7-9e43a8067c59"
/> | <img width="400" alt="after: Team, $300 USD/mo, Renews on, Manage
billing/Change plan, design perks"
src="https://github.com/user-attachments/assets/adb0f767-d508-4455-ad8e-ee2d6ac419dc"
/> |
| Team plan — ending (cancelled) | <img width="400" alt="before: Expires
Jul 10, 2026"
src="https://github.com/user-attachments/assets/cb4bb978-4e8b-4372-8ecc-7265f477a828"
/> | <img width="400" alt="after: Ends on Jul 10, 2026"
src="https://github.com/user-attachments/assets/5466b99a-d016-4eba-a60f-99b87bd2693e"
/> |
| Personal — active (real data) | <img width="400" alt="before: Renews
Mar 10 2027, Manage Payment"
src="https://github.com/user-attachments/assets/34018400-930b-4147-bd0d-398fb4d159ee"
/> | <img width="400" alt="after: Renews on Mar 10 2027, Manage billing"
src="https://github.com/user-attachments/assets/87af7fa7-28cf-4cdf-b9a2-158b2a6eb979"
/> |
| Personal — no subscription | <img width="400" alt="before: generic
not-on-a-subscription prompt"
src="https://github.com/user-attachments/assets/7ee5b36e-1e07-4630-a9b7-680f4fad349b"
/> | <img width="400" alt="after: Free $0 USD/mo header + Subscribe +
What's included"
src="https://github.com/user-attachments/assets/1eda1791-e3de-46c3-bb69-df0365545211"
/> |



---

## Perk descriptions — designer QA (DES `3253-16079`)

CDP-verified the Plan & Credits **perk list** per current plan against
Figma. Captured at the full 1280px settings layout (width fix: #12849;
the perk text/content is identical at the current 960px).

| Plan | "Includes" header | Perks shown |
|---|---|---|
| Free (no subscription) | What's included: | 10 min max runtime |
| Personal — Pro | Your plan includes: | 1 hr max run duration · RTX
6000 Pro (96GB VRAM) · Add more credits whenever · Import your own LoRAs
|
| Team | Your plan includes everything in **Pro**, plus: | Invite
members · Members can run workflows concurrently · Shared credit pool
for all members · Role-based permissions |

**Free — Figma `3253-16079` (left) vs implementation (right):**

| Figma | Implementation |
|---|---|
| <img width="480" alt="Figma DES 3253-16079 free settings"
src="https://github.com/user-attachments/assets/cfe79570-f3ba-4627-a8eb-348b9158f6ac"
/> | <img width="480" alt="App free settings — What's included: 10 min
max runtime"
src="https://github.com/user-attachments/assets/d89898e4-d819-486c-9b4f-c2fd61916783"
/> |

**Personal — Pro:**

<img width="640" alt="App personal Pro — Your plan includes"
src="https://github.com/user-attachments/assets/adc2fd9f-d249-469f-b947-1ec8f674cbb0"
/>

**Team:**

<img width="640" alt="App team — Your plan includes everything in Pro,
plus"
src="https://github.com/user-attachments/assets/e7378067-11a2-411b-b37b-98c8aecb82b1"
/>

Open items (design):
- Free perk now reads **"10 min max runtime"**
(`subscription.maxDuration.free` set to 10 min) per Figma `3253-16079` —
 applied in this PR.
- Personal-plan perk **stacking** (show lower-tier perks under the
current tier) is an unresolved Figma thread on this node — not
implemented.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:19:35 +00:00
Wei Hai
f5f1e69cd8 feat(auth): Cloudflare Turnstile on email signup (origin-gated) (#12924)
Adds a Cloudflare Turnstile widget to the email/password signup form
(web + desktop). The frontend renders the widget and attaches its token
to the signup request; the verification decision is made server-side.

## Design — config-driven, no origin sniffing

* The widget renders **iff** the `signup_turnstile` mode is `shadow` or
`enforce` **and** a `turnstile_sitekey` is present — both delivered via
cloud remote config. OSS / local builds receive no remote config, so it
never renders. Gating is a pure `isTurnstileEnabled(mode, siteKey)`; an
unknown mode normalizes to `off`.
* Submit is blocked only in **enforce**; **shadow** never blocks.
* The token is sent as `turnstile_token` (snake_case, optional) on the
customer-creation request.
* **OAuth** never renders the widget or sends a token (federated
providers are exempt).

## Behavior

* **Decision is server-side** — the frontend only renders the widget and
attaches the token; the backend verifies it and decides allow/block.
* **Mode-driven** — `off` (no-op) / `shadow` (render + attach, never
blocks) / `enforce` (blocks submit until solved).
* **Config-gated** — no `isCloud`/origin check in the client; the widget
is driven purely by the presence of the mode flag + sitekey in remote
config.
* **Fail-safe to off** — an unknown/missing mode or a missing sitekey
resolves to "don't render", so the feature is a no-op until both are
configured.
* The sitekey is a public, client-side value delivered per environment
via remote config; in dev it falls back to Cloudflare's always-pass test
sitekey.

## Files

New: `config/turnstile.ts`, `composables/auth/useTurnstile.ts` (+ test),
`composables/auth/turnstileScript.ts`,
`components/dialog/content/signin/TurnstileWidget.vue`. Edited:
`SignUpForm.vue`, `SignInContent.vue`, `useAuthActions.ts`,
`authStore.ts` (+ test), `remoteConfig/types.ts`,
`locales/en/main.json`.

## Flow

```mermaid
sequenceDiagram
    actor U as User
    participant FE as Signup form
    participant CF as Cloudflare Turnstile
    participant API as Backend signup API

    Note over FE: renders only when mode is shadow or enforce<br/>and a sitekey is present
    U->>FE: open email/password signup
    FE->>CF: load widget with sitekey
    CF-->>U: challenge (usually invisible)
    U-->>CF: solve
    CF-->>FE: token (single-use, short-lived)
    U->>FE: submit
    FE->>API: signup request with turnstile_token
    Note over API: verifies the token server-side and<br/>decides allow/block (shadow never blocks)
    API-->>FE: allowed, or blocked in enforce
```

## Rollout

Config-driven and a no-op until enabled:

1. **Merge + deploy** the FE — no visible change while the mode is `off`
/ no sitekey.
2. **Set** the `turnstile_sitekey` in remote config per environment.
3. **`signup_turnstile=shadow`** — the widget renders and attaches the
token; the server observes and never blocks.
4. → **`enforce`** — the FE blocks submit until the challenge is solved.

Kill switch: set the mode back to `off` and the widget stops rendering.

## Refactor: shared script loader

The Turnstile script loader was extracted to
`utils/loadExternalScript.ts` (`createScriptLoader`) and now also backs
the existing Typeform embed loader, removing duplicated
singleton/timeout/cleanup logic. Minor behavioral change: when a
matching `<script>` tag already exists in the DOM, the loader polls for
the global to become ready instead of attaching a `load` listener (which
may have already fired).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:03:48 +00:00
Simon Pinfold
c202e3cc2d Allow Glary Bot through CLA (#13149)
## Summary

- Add `Glary Bot` to the CLA Assistant allowlist.

## Context

PR #13146 is blocked because its commit author is `Glary Bot
<bot@glary.dev>`, which GitHub does not resolve to a GitHub user. The
CLA action checks the unresolved commit author name after failing to
find a linked GitHub account, so the existing `*[bot]` GitHub App
allowlist does not apply.

## Validation

- Ran `git diff --check`.
2026-06-25 22:48:19 +00:00
AustinMroz
9d20bfd410 Add center dividing line to image compare node (#13132)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/37afb473-161c-4350-881e-0ea908e28777"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/d5acca61-3687-4c15-8029-ef2c88a06944"
/>|
2026-06-25 20:38:59 +00:00
Alexander Brown
c4ed398511 Add assertive profile to review settings (#13120)
## Summary
Rabbit too nice.
2026-06-25 20:28:49 +00:00
AustinMroz
33f0993657 Add long widget values to tooltips (#12864)
If a widget value is long (> 10 characters) and on a known single-line
widget (`number`, `combo`, or `string), then the widget's full value
will be added to the tooltip.

Additionally, margins on combo widgets are slightly tweaked so more of
the text displays before truncation occurs.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/fefd76e9-6511-4e98-80f6-030a6dc34fb8"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/0cbc100d-066e-4272-afe9-795e56c12353"
/>|
2026-06-25 19:25:02 +00:00
AustinMroz
5b62ec2431 Update CLA allowlist (#13141)
It's `github-actions` without the `[bot]`. This was blocking every PR
that contained updated browser test expectations.

Additionally, the action already included an allow list for every
account ending in `[bot]`. This made half the entries redundant.
2026-06-25 18:30:48 +00:00
jaeone94
604e4d6414 Remove redundant Enter Subgraph action from Errors tab (#13136)
## Summary

Remove the redundant **Enter Subgraph** action from Errors tab node
cards. This button should not be part of the updated Errors tab design;
it remained from the previous implementation and its removal was missed
when the new interaction model was introduced.

## Changes

- **What**: Removed the Errors tab `Enter Subgraph` button from
`ErrorNodeCard`, along with the `enterSubgraph` event plumbing in
`TabErrors`.
- Removed the now-unused `useFocusNode().enterSubgraph()` helper path,
since the Errors tab no longer has a separate subgraph-only action.
- Removed the `ErrorCardData.isSubgraphNode` flag and its population in
`useErrorGroups`, because it only existed to decide whether to show this
button.
- Removed the Storybook story and unit-test expectations that were
specifically tied to the removed button/flag.
- Removed the now-unused English `rightSidePanel.enterSubgraph` i18n
entry. Non-English locale files are intentionally left untouched per the
repo's localization update policy.

## Why

The Errors tab already has a **Locate node on canvas** action. For
errors inside subgraphs, that action navigates into the relevant
subgraph and centers the target node on the canvas. The removed **Enter
Subgraph** action was therefore a weaker duplicate: it entered the
subgraph and fit the view, but did not provide the same direct
target-node positioning.

Keeping both actions made the card UI more crowded and exposed two very
similar navigation paths with overlapping intent. The updated design
should only keep the more useful locate action, so this PR removes the
stale duplicate surface rather than adding another hidden/negative
assertion around it.

## Review Focus

Please verify that this only removes the Errors tab-specific action. The
normal node footer/canvas subgraph navigation behavior remains
untouched.

Validation run locally:

- `pnpm exec vitest run
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`


## Screenshot 

Before
<img width="335" height="595" alt="스크린샷 2026-06-25 오후 5 33 37"
src="https://github.com/user-attachments/assets/545f80e9-68bb-45ef-a4da-0a41012269f6"
/>

After
<img width="344" height="591" alt="스크린샷 2026-06-25 오후 5 34 24"
src="https://github.com/user-attachments/assets/7c1f1bf6-c5fd-4a43-9b5c-1392246070a8"
/>
2026-06-25 11:25:49 +00:00
Dante
312a25b63f feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953)
## What this does

Collapses the personal-vs-team billing dispatch so it keys ONLY on the
build/flag (`teamWorkspacesEnabled ? 'workspace' : 'legacy'`). Personal
now flows through `useWorkspaceBilling` (`/api/billing/*`), same as team
("personal plan = single-seat workspace"). This converges status /
balance / subscribe / preview / cancel / portal in one change.

Dispatch sites collapsed:
- `useBillingContext.ts` — `type` computed: dropped the
`store.isInPersonalWorkspace` branch → flag-only.
- `useBillingContext.ts` — D3 subscription→store mirror watch: dropped
the `isInPersonalWorkspace` early-return guard so personal also mirrors
into the workspace store.
- `useSubscriptionDialog.ts` — `useWorkspaceVariant` compound predicate
→ flag-only (personal + flag-on now uses the workspace required-dialog
variant).
- `SubscriptionPanel.vue` — already flag-only
(`v-if="teamWorkspacesEnabled"`); no change needed.

## Kept (Risk #6)

- The ~11 raw `workspace.type === 'personal'` checks in
`teamWorkspaceStore.ts` — workspace-TYPE membership logic
(can-delete/leave, fetch-members, switcher), NOT billing dispatch.
Untouched.
- `useLegacyBilling` / `useSubscription` / authStore billing methods
kept intact for the flag-OFF (OSS/Desktop) path.

## Flag-off unchanged

Flag-OFF (OSS/Desktop) still selects `legacy` (`/customers/*`). Verified
by unit test.

## Tests

- `useBillingContext`: flag-ON → personal selects `workspace`; flag-OFF
→ `legacy`; D3 mirror now fires for personal under flag-on.
- `useSubscriptionDialog`: flag-ON → workspace required-dialog variant
for personal; flag-OFF → legacy personal variant.

## Follow-up (deferred, not in this PR)

Post-flip cutover deletion of `useLegacyBilling`-only components:
`PricingTable.vue`, `SubscriptionPanelContentLegacy.vue`,
`TopUpCreditsDialogContentLegacy.vue`, `CurrentUserPopoverLegacy.vue`,
`subscriptionCheckoutUtil.ts`, `useSubscriptionCancellationWatcher.ts`.

- Fixes part of FE-903 (B1)
2026-06-25 06:59:00 +00:00
Dante
b414b1f082 feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954)
Renders the inline **"Invite your team"** block in the team variant of
FE-934's "You're all set" success card
(`SubscriptionSuccessWorkspace.vue`), so a buyer can invite teammates
right after a team-plan upgrade (DES-394, Figma 3084-18651).

- New shared `InviteMembersForm.vue` (chips / `TagsInput` multi-email
form); seats capped via `useBillingContext.getMaxSeats(tierKey)`; submit
via `workspaceApi.createInvite`.
- Team upgrades only — personal / single-seat plans show the plain
success card; gated on `teamWorkspacesEnabled` + a team plan.
- `workspace_invite_sent` telemetry distinguishing a post-upgrade invite
from a Settings invite; success-card i18n + Storybook story.

**Stacked on FE-934 #12975** (`jaewon/fe-934-team-subscribe-wire`, the
success-card host). The PR base is that branch, so this diff is FE-965's
delta only. Re-using the same form in the Settings invite dialog is out
of scope here (belongs with FE-768 / a follow-up).

## Screenshots

| Team upgrade — invite block | Personal / non-team — no invite block |
|---|---|
| <img
src="https://github.com/user-attachments/assets/be5450fe-2b83-46bd-afbc-00e6d33590b7"
width="420" /> | <img
src="https://github.com/user-attachments/assets/a91909c7-7629-42ef-80b6-45fdb070a0e8"
width="420" /> |

Storybook: `Components/SubscriptionCheckoutSteps` →
`TeamSuccessWithInvite` (with block) / `SuccessAllSet` (without).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 05:54:04 +00:00
Dante
f346dd93d2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
Dante
07205c6e43 feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734)
## Summary

### AS IS 
<img width="1340" height="798" alt="Screenshot 2026-06-10 at 12 22 36
AM"
src="https://github.com/user-attachments/assets/61636fa3-e80c-427b-855b-499e1eca67da"
/>

### TO BE

<img width="1301" height="793" alt="Screenshot 2026-06-10 at 12 22 39
AM"
src="https://github.com/user-attachments/assets/62d9f5a6-da92-45df-94e7-cd3c244249f9"
/>

### Empty states ([added to DES-247 on
2026-06-11](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3349-29750))

| 0 monthly credits | 0 credits |
| --- | --- |
| <img alt="credits-empty-monthly"
src="https://github.com/user-attachments/assets/b3c55d3b-79b0-47b1-9795-c8bf69d5efe2"
/> | <img alt="credits-empty-all"
src="https://github.com/user-attachments/assets/919081d6-64e1-483b-9c04-6b085243ebc1"
/> |

Consolidate the divergent Settings credits surfaces into one
facade-driven **CreditsTile**, implementing the DES-247 redesign so
personal and team modes always render the same balance from
`useBillingContext`.

## Changes

- **What**:
- New `CreditsTile.vue` — total + `remaining`, a stacked
monthly/additional progress bar, colored breakdown rows (`Monthly
(refills …)` / `Additional`), refresh, and a permission-gated *Add
credits* / *Upgrade to add credits* action. Owns the post-checkout
(`focus` / `pending_topup`) balance refresh.
- Extracted the duplicated inline credits card out of
`SubscriptionPanelContentWorkspace.vue` **and**
`SubscriptionPanelContentLegacy.vue` onto the shared tile.
- Replaced `LegacyCreditsPanel.vue` (read `authStore.balance` directly)
with `CreditsPanel.vue` routed through the tile; repointed
`useSettingUI` and deleted the legacy panel.
- `creditsProgress.ts` pure helper for the bar math + numeric credit
getters on `useSubscriptionCredits`.
  - i18n keys for the unified tile labels.
- DES-247 responsive variants via CSS container queries: below ~350px
tile width the `{used} used` label, `remaining` suffix, and breakdown
subtitle drop and the additional-credits value stacks under its label;
below ~230px the monthly summary compacts (`105K left of 200K`).
Additional-credits tooltip copy aligned with the updated design (per
design feedback).
- Empty states (added to DES-247 via Slack on 2026-06-11, low priority):
once the monthly allowance is depleted, an info notice renders under the
total (`Monthly credits are used up. Refills {date}` / `You're now
spending additional credits.`), the monthly bar section dims to 30%
opacity, and an `IN USE` pill marks *Additional credits*; once
everything is depleted the notice switches to `You're out of credits.
Credits refill {date}` and *Add credits* swaps to the `inverted`
(filled-white) Button variant. Gated on a loaded balance so the notice
never flashes while fetching.
- **Dependencies**: Stacked on **#12622 (FE-904 / B2)** for the facade
`tier` / `renewalDate` fields — base this PR against that branch;
retarget to `main` once FE-904 merges.

## Review Focus

- The tile reads everything from the facade (`balance.*Micros` as cents
→ credits, `subscription.tier`/`renewalDate`), so legacy and workspace
modes share one source.
- Monthly allowance still comes from `getTierCredits` (hardcoded tier
nominal). With real data the monthly *remaining* can exceed the nominal
(rolled-over credits), so the bar clamps to a full segment — same
semantics as the prior `{monthly} / {planTotal}` display; the canonical
allowance is a BE-1047 follow-up.
- `LegacyCreditsPanel` deletion: `CreditsPanel.vue` retains the
usage-history table + help links and reads the facade.

## Testing

- Unit/component (36 green): `CreditsTile.test.ts` (render, zero-state,
free-tier, permission gating, add-credits, mount+manual refresh, plus
the empty states: depletion notice copy, `IN USE` badge, `inverted`
button when fully out, no-flash-while-loading guard),
`creditsProgress.test.ts` (clamping/stacking math),
`useSubscriptionCredits.test.ts` (`*_micros`-as-cents), and
`SubscriptionPanel.test.ts` updated for the extracted tile.
- E2E (`@cloud`): `browser_tests/tests/dialogs/creditsTile.spec.ts`
boots the cloud app against mocked Firebase auth + stubbed boot
endpoints (no backend) and asserts the tile's total / progress bar /
monthly+additional breakdown / add-credits in Settings ▸ Workspace ▸
Plan & Credits, then resizes to a narrow viewport and asserts the
responsive variant (labels hidden, compacted `11K left of 21K`). A
second test boots with a drained monthly balance (0-monthly notice + `IN
USE` badge), then re-mocks a fully drained balance and refreshes the
tile in place to assert the out-of-credits state. Both pass locally
against a cloud dev server; runs in the `cloud` CI project. Drives a raw
page because the shared `comfyPage` fixture expects the OSS devtools
backend.
- Screenshot-verified the tile at the three DES-247 reference widths
(448 / 235 / 204px) against the Figma Responsiveness section — 1:1.
- Verified live in the running app (Settings ▸ Workspace ▸ Plan &
Credits) against the authenticated backend — renders 1:1 with DES-247.
The empty-state screenshots above were captured the same way
(authenticated app, real Pro subscription, balance endpoint stubbed to
the depleted values via CDP).
- `pnpm typecheck` / `typecheck:browser` / `lint` / `knip` green.

Implements FE-964 (DES-247).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:44:59 +00:00
Dante
c39f378ba4 feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975)
## Summary

Two related streams of FE-934 checkout/billing work on this branch:



https://github.com/user-attachments/assets/af629def-543e-4bcd-894d-b35aa032fe0a



1. **Team-plan subscribe** wired to the BE-1254 credit-stop contract
(replaces the "coming soon" toast stub).
2. **Checkout / confirm screen redesign** aligned to the updated DES
mockup — yearly pricing display, dialog navigation, and the plan-change
confirm.

<img width="1078" height="427" alt="team subscribe"
src="https://github.com/user-attachments/assets/2b5f7192-3c91-4e2d-b495-832ca5a26657"
/>

## Changes

### Team-plan subscribe (credit-stop contract)

- Team subscribe sends `{ plan_slug: team_per_credit_monthly |
team_per_credit_annual, team_credit_stop_id, billing_cycle }` to `POST
/api/billing/subscribe` and handles the response like the personal path
(`subscribed` → success, `needs_payment_method` → payment URL,
`pending_payment` → poll). Slider stops come from `GET
/api/billing/plans → team_credit_stops` via `mapApiTeamCreditStops`,
falling back to the hardcoded DES-197 stops so OSS / pre-deploy still
render. `preview-subscribe` is unchanged — the team confirm step is
display-only.
- **Internal API change**: `workspaceApi.subscribe(planSlug, returnUrl?,
cancelUrl?)` → `subscribe(planSlug, options?: SubscribeOptions)`; the
billing facade (`useBillingContext` / `useWorkspaceBilling` /
`useLegacyBilling`) and callers were updated to match.

### Checkout / confirm screen redesign (DES mockup)

- **Yearly confirm**: headline is now the ÷12 monthly-equivalent with a
`{total} Billed yearly` (yearly) / `Billed monthly` (monthly) subtitle;
credits show `Each year credits refill to` (×12) for yearly; `Starting
today` → `Starts today`. The team confirm now receives the active
billing cycle (the subtitle was missing because it wasn't passed), and
the redundant header credits/month line was dropped.
- **Navigation**: the pricing table stays mounted (`v-show`, not `v-if`)
so the plan / billing-cycle / credit-stop selection survives a round
trip to the confirm step and back; **Backspace** mirrors the back arrow
(ignored while an input/textarea/contenteditable is focused).
- **Plan-change confirm**: rewritten from the 2-column current→new
comparison to the **single-plan layout**, branching on
`previewData.is_immediate` — immediate upgrades show prorated line items
(`new_plan.price_cents − cost_today_cents` = credit) + upfront (yearly)
/ monthly credit refill + a prorated total ("Confirm upgrade");
scheduled downgrades show `Starts {date}`, $0 due today, and an "After
that" block ("Confirm change").
- **Storybook**: `SubscriptionCheckoutSteps` stories for each new-sub /
upgrade / downgrade variation (props-driven, no API).

## Review Focus

- **Merge gate (team subscribe)** —  **resolved**: BE-1254 is now
merged in cloud `main`. The live `GET /api/billing/plans` returns
`team_credit_stops` (`TeamCreditStop` struct + validation in
`common/billing/catalog/catalog.go`, served for every workspace,
asserted by the `billing_credit_stops_contract` smoke test). The gate is
lifted.
- **Fallback safety**: with the contract live, the hardcoded DES-197
stops are now purely the OSS / pre-deploy fallback — they render only
when the API doesn't supply `team_credit_stops`. In that window a real
subscribe is still impossible (no stop `id`) and surfaces a
`teamPlan.unavailable` toast. Open question retained: hide/disable the
team CTA in that window instead of toasting?
- **Plan-change scope**: the single-plan confirm redesign covers
**personal** changes (the real `previewSubscribe` path). **Team** plan
changes route through the display-only team confirm and aren't wired to
`previewSubscribe` (BE left `PreviewSubscribeRequest` as `plan_slug`
only) — team-change proration is a follow-up.
- **Dead locale keys**: the old 2-column transition keys
(`everyMonthStarting`, `youllBeCharged`, `proratedRefund`,
`proratedCharge`, `creditsRefillTo`, `switchToPlan`, `starting`, `ends`,
`confirmPlanChange`) are now unused — can be removed in a follow-up
cleanup.
- **Out of scope**: the success "Your change is scheduled" variant from
the mockup.
- Based on `fe-934-unified-pricing-table`; base + `main` merged in to
resolve conflicts (PR is now mergeable).

## Verification

- Confirm / transition component tests green
(`SubscriptionAddPaymentPreviewWorkspace`,
`SubscriptionTransitionPreviewWorkspace`); oxlint / oxfmt clean locally;
full typecheck runs in CI.
- Each variation viewable in Storybook →
`Components/SubscriptionCheckoutSteps`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:27:44 +00:00
Dante
00c7eaef79 feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708)
## Summary

Behind `unified_cloud_auth` (default OFF), flip the two token accessors
so every cloud request rides the single Cloud JWT minted in PR2. This is
the consumer-flip phase of FE-950: PR2 built the dormant `unifiedToken`
slot; this PR makes consumers read it — and surfaces the
permanent-auth-failure path that the flip turns from graceful
degradation into a hard stop.

Stacked on #12704 (PR2), now merged; base is `main`.

## Changes

- **What**:
- `getAuthHeader()` — flag ON returns `{ Authorization: Bearer
<unifiedToken> }` (or `null` if unminted), with **no** Firebase/API-key
fallback. Flag OFF keeps the exact workspace → Firebase → API-key
cascade.
- `getAuthToken()` — flag ON returns the unified Cloud JWT (or
`undefined`); flag OFF keeps workspace → Firebase.
- Both accessors are the single seam every cloud consumer already routes
through, so the flip propagates automatically with **no edits** to
`fetchApi` (`scripts/api.ts`), `/customers/*` (authStore),
`workspaceApi`, the WebSocket (`api.ts:568`), or backend-node auth
(`app.ts:1593`).
- **Surface permanent auth failures** (answers @pythongosssss's review
on PR2). Under the flag there is no Firebase fallback, so a silent
`clearUnifiedContext()` wipe would strand every cloud request until
manual re-login — unlike the legacy path, which degrades to the Firebase
token. `refreshUnified()` and `mintAtLogin()` now emit a user-facing
error toast (keyed by error code off the existing `workspaceAuth.errors`
i18n) on the permanent codes (`ACCESS_DENIED` / `WORKSPACE_NOT_FOUND` /
`INVALID_FIREBASE_TOKEN` / `NOT_AUTHENTICATED`). `mintAtLogin()` now
resolves `false` on a permanent failure instead of rejecting an
unhandled `void`ed promise. Transient failures stay silent (proactive
refresh still retries). Also trims the verbose unified-lifecycle
comments flagged in review.
- **Breaking**: None. Flag OFF is byte-for-byte the current cascade.

## Review Focus

- **Single token, no fallback under the flag.** Tests assert
`getAuthHeader`/`getAuthToken` return only the unified token and never
call `getIdToken` or the API-key store; they return `null`/`undefined`
(not a fallback) when the token is unminted.
- **Surfacing, not recovery.** This PR makes the terminal state
*visible* (toast); the existing router auth-guard still redirects to
login on the next navigation. Active recovery (automatic re-mint on 401)
stays in the deferred safety-net PR so the toast is never a dead-end
"please re-login" with the fix one PR away.
- **Flag-OFF parity.** The full existing cascade suite runs with
`unifiedCloudAuthEnabled = false` (the `beforeEach` default) and stays
green.

## Deferred (intentional)

- **`acceptInvite` is left unchanged — still Firebase-authed.** It is
the one cloud call that intentionally keeps the raw Firebase token,
because the invite is accepted *before* the user is a member of the
target workspace. Promoting it to the unified Cloud JWT first needs a
quick check that `POST /invites/:token/accept` accepts a personal-scoped
Cloud JWT for a not-yet-member; deferred until that is verified.
`getFirebaseAuthHeader` / `getFirebaseAuthHeaderOrThrow` stay defined
(their removal belongs to the later cleanup ticket, FE-951). No
`workspaceApi.ts` change in this PR.
- **The reactive 401 re-mint + retry safety net is a follow-up.** A
clean place to intercept a `401` and re-mint once does not exist yet:
cloud requests use raw `fetch` (`/customers/*`, `/auth/token`) plus
several independent axios clients (`workspaceApi`,
`customerEventsService`, registry, manager), with no shared response
interceptor. PR2's `remintUnifiedOnce()` primitive is ready, and the
proactive buffer-based refresh (`refreshUnified`) already covers the
common token-*expiry* case, so this cross-cutting safety net (plus
deciding whether the surfacing toast escalates to a guided re-login CTA
once remint exists) lands in its own focused PR before any production
rollout. Note this is orthogonal to the surfacing above: proactive
refresh prevents expiry; it cannot prevent *revocation*, which is
exactly what triggers the now-surfaced permanent-error path.

## Tests

- Extended `authTokenPriority.test.ts`: flag-ON `getAuthHeader` returns
only the unified JWT (Firebase + API-key + workspace untouched) and
`null` when unminted; flag-ON `getAuthToken` returns the unified JWT
(not Firebase) and `undefined` when unminted. Existing cascade tests
prove flag-OFF parity.
- Added to `useWorkspaceAuth.test.ts` (red-green + regression lock): a
permanent refresh error toasts the **correct i18n key for each of the
four permanent codes** (`it.for` over 403/404/401 + a
lost-Firebase-token `NOT_AUTHENTICATED` case) and clears the slot; a
permanent login-mint error toasts and resolves `false`. Negative guards
prove the surfacing is **error-only and flag-scoped**: a transient (5xx)
refresh does **not** toast and keeps the slot, a **successful** re-mint
does not toast, and the unified lifecycle **never toasts when the flag
is OFF** (even against a rejecting backend).

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| `test: cover permanent unified-auth error surfacing` | 🔴
Red
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27455949404))
| Proves the tests catch the silent-failure gap |
| `fix: surface permanent unified-auth errors instead of failing
silently` | 🟢 Green
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27456200098))
| Proves the surfacing resolves it |

Part of FE-950 (single Cloud-JWT provider at login, Phase 1).
2026-06-25 03:42:57 +00:00
Dante
09d59616f5 feat(billing): deep link to open the pricing table (FE-1104) (#13001)
## Summary

Adds an in-app deep link that opens the pricing table directly, for
driving pilot users straight to subscribe (request from nav/Alex).
Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104).

- `/?pricing=1` — on app load, open the pricing table.
- `/?pricing=team` / `/?pricing=personal` — open it on the Team /
Personal plan tab (via the existing `UnifiedPricingTable`
`initialPlanMode`).
- Gated to the **original owner** via
`useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal
user, or a team workspace's original owner). A member or a promoted
owner is a **silent no-op**: the app loads normally, the param is
stripped, no 404 / error / toast.
- Off-cloud (OSS): the loader isn't instantiated, so the param is
ignored.
- Survives the login redirect via the preserved-query system, same as
`?invite` / `?create_workspace`.

## How

Mirrors the established URL-loader pattern (`useInviteUrlLoader` /
`useCreateWorkspaceUrlLoader`):

- `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing`
namespace + tracker key.
- New `usePricingTableUrlLoader.ts` — hydrate preserved query, read
`pricing`, strip the param + `clearPreservedQuery` in a single replace
before any await, then `await fetchMembers()` (resolves the
original-owner gate; no-ops for personal) and open the table only when
the gate allows.
- `GraphCanvas.vue` — call the loader in `onMounted` after the
create-workspace loader (cloud only; not gated on the team-workspaces
flag so it also drives personal/legacy users).
- `useSubscriptionDialog.ts` — new `'deep_link'` value on
`SubscriptionDialogReason`.

## Telemetry

Eligible opens emit the existing `subscription_required_modal_opened`
PostHog event with the new `reason: 'deep_link'`. Ineligible-click
bounce rate is derivable from the autocaptured pageview URL
(`?pricing=…`), so no new event plumbing.

## Stacking / dependencies

This feature needs two sibling stacks off `main`:

- **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` +
`showPricingTable({ planMode })`.
- **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate.
**Merged into this branch**, so the diff against the FE-934 base
includes FE-770's changes until it lands. Review the single
`feat(billing): deep link…` commit. Once both land on `main`, rebase
onto `main` and the diff collapses to just this feature.

Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up.
End-state: swap the FE original-owner heuristic for the BE
workspace-level `is_original_owner` flag when it lands (removes the
members-fetch).

## Tests

- Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an
original owner; `team`/`personal` tab preselect; silent no-op +
param-strip for a member/promoted owner; proves the gate is read only
after `fetchMembers` resolves; preserved-query restore;
empty/non-string/absent/unrecognized param; members-fetch failure
strips+clears without opening.
- E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`,
`@cloud`, 4 cases, verified locally): personal owner opens + URL
stripped; `?pricing=team` lands on the active Team tab; team original
owner opens (real `is_original_owner` + email gate); team member is a
silent no-op + URL stripped.
- Typecheck + related unit suites (`useSubscriptionDialog`,
`useWorkspaceUI`, `teamWorkspaceStore`) green.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-25 02:30:07 +00:00
Dante
e8ea16463f feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759)
## Summary

Redesigns the Settings ▸ Workspaces Members invite flow to DES-186:
email-chips invite dialog, Resend/Cancel pending-invite actions, and
explicit team-plan gating.

## Changes

- **What**:
- `InviteMemberDialogContent`: single-email link-generation flow →
comma/Enter/paste-separated email chips (`TagsInput`); batch
`createInvite` via `Promise.allSettled` — on partial failure, failed
emails stay as chips with an error toast; success shows an Invited
state.
- `PendingInvitesList`: inline Copy-link/Revoke buttons → `⋯` overflow
menu with **Resend invite** / **Cancel invite**. New
`teamWorkspaceStore.resendInvite` issues the fresh invite *before*
revoking the old one, so a failed resend never destroys the original.
- FE-built invite links removed
(`getInviteLink`/`createInviteLink`/`copyInviteLink`,
`PendingInvite.token`) — invite delivery is BE email per DES-186.
- New `useTeamPlan` composable: explicit `isOnTeamPlan` (seat-based
`maxSeats > 1` proxy until BE-1254 exposes the plan signal) replaces
`isSingleSeatPlan` branching in
`WorkspacePanelContent`/`useMembersPanel`.
- Personal/no-team-plan state per Figma 2993-14604: "To add teammates,
upgrade your plan." banner + **Upgrade to Team**; "Need more members?
Contact us" footer; upsell dialog copy → Team-plan framing; members sort
by Role column (was Join date).
- **Header layout per design (2nd commit)**: `[Search][Invite +][⋯
workspace menu]` moved from the dialog tab row into the members card
header row (the "N of M members" row) — the tab row right side is empty
in every DES-186 frame. Workspace menu extracted to
`WorkspaceMenuButton.vue`.
- **Single-member gating per design annotations**: when the owner is
alone — search hidden ("Show if more than 1 members"), Active/Pending
segmented tabs hidden, role column hidden, and the Members tab label
drops its count ("Remove '(0)' as well if it's just 1 member"). Tabs
reappear if pending invites exist so they stay manageable.
- **Breaking**: `teamWorkspaceStore` invite-link API removed (no
external consumers found in-repo).

## Review Focus

- Resend semantics (create-first, then revoke) and pending-invites state
update in `teamWorkspaceStore.resendInvite`.
- Invite-link removal assumes BE sends invite emails (DES-186 annotation
"Resends the invite email"). If BE email delivery is not yet live, this
PR should wait on that confirmation.
- Gating matrix: team-active owner → enabled; team at seat cap →
disabled+tooltip; non-team plan → upsell dialog; personal workspace →
disabled.
- Workspace `⋯` menu (Edit/Delete/Leave) now lives only on the Members
tab card header, matching DES-186 — the Plan & Credits tab no longer
exposes it (design shows the plan-card `⋯` there instead, shipped via
FE-964/FE-768b).

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session; team-plan states use client-side XHR-level API stubs for
subscription/members/invites since the test workspaces are
unsubscribed).

| Flow | Before (main) | After (this PR) |
|---|---|---|
| Members list — team plan | <img width="400" alt="before members"
src="https://github.com/user-attachments/assets/be214b95-4783-47ff-9539-59c8a33b5eb9"
/> | <img width="400" alt="after: Search/Invite/menu in the card header
row, Role column, design demo data"
src="https://github.com/user-attachments/assets/841c89d5-2a29-4eed-9c72-4a5ee8bee9f4"
/> |
| Pending invites — row actions | <img width="400" alt="before pending:
inline copy-link/revoke icons"
src="https://github.com/user-attachments/assets/1703850e-86bc-4735-81b3-7530c01ad46f"
/> | <img width="400" alt="after pending: overflow menu with
Resend/Cancel invite"
src="https://github.com/user-attachments/assets/9b1bbe03-d82b-4cf6-86f2-e01d7b898ae7"
/> |
| Team workspace with 1 member | (same chrome as members list: search +
tabs always shown) | <img width="400" alt="after: no search/tabs/role,
tab label without count, Invite + menu in card row"
src="https://github.com/user-attachments/assets/22c7f68a-eb0f-49a9-a203-516cd9b7e02d"
/> |
| Invite dialog | <img width="400" alt="before: single email, Create
link"
src="https://github.com/user-attachments/assets/c19d8bbb-feb5-4fe4-8541-6d52e6ab6600"
/> | <img width="400" alt="after: comma-separated email chips"
src="https://github.com/user-attachments/assets/101fdc7b-d6e0-4f7d-8966-894bbf16b4aa"
/> |
| Invite dialog — submit result | (copies a link) | <img width="400"
alt="after: Invited success state"
src="https://github.com/user-attachments/assets/f539a88c-0250-434c-bf4a-6ce714b30398"
/> |
| Upsell — invite without team plan | <img width="400" alt="before:
subscription required, Creator plan copy"
src="https://github.com/user-attachments/assets/bb2cb9fd-f298-4cb0-b39a-6d59061dcea1"
/> | <img width="400" alt="after: Team plan required, Upgrade to Team"
src="https://github.com/user-attachments/assets/45170ed5-63bd-469a-af12-197a8b7e09ee"
/> |
| Personal workspace — Members tab | (create-workspace hint text) | <img
width="400" alt="after: upgrade banner + disabled Invite"
src="https://github.com/user-attachments/assets/a0ae664b-2d20-4d87-900d-7a36872ecde3"
/> |

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Members half; Plan & Credits panel ships separately stacked on FE-964)
2026-06-25 02:29:46 +00:00
Matt Miller
3cead52236 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-22 13:10:06 -07:00
GitHub Action
bad3bc20ec [automated] Apply ESLint and Oxfmt fixes 2026-06-19 02:50:57 +00:00
mattmiller
1fa46b1586 fix: guard customerCreated and customerRecovery against session races
Two concurrent-auth bugs in recoverMissingCustomer:

1. Stale rejection race: the .catch cleared customerRecovery
   unconditionally, so a failed promise from session N could null
   out the in-flight promise for session N+1. Fix: capture the
   promise in a local ref and only null customerRecovery when it
   still points to this attempt.

2. createCustomer bleed: customerCreated.value = true fired after
   await even if the auth state had already reset to a different
   (or absent) session. Fix: snapshot the user uid before the
   fetch and skip the assignment if the session changed mid-flight.

Both races are covered by new Vitest tests.
2026-06-18 19:46:52 -07:00
Matt Miller
371afa1235 fix: harden customer 409 recovery (review findings)
- Reset customer provisioning state on auth state changes: without this,
  a second account in the same browser session was short-circuited by
  the previous account's memoized recovery and stayed stuck on 409s.
- Gate recovery on the missing-customer 409 body ("Failed to find
  customer") so business-level 409s from billing endpoints don't
  trigger spurious customer creation or a blind retry of a payment
  request. Also enforces the /customers/* contract via a URL path
  guard now that the wrapper is exported.
- Route addCredits' pre-flight customer creation through the shared
  recovery memo so it can't race a concurrent 409-triggered recovery
  into duplicate POST /customers.
- Guard the retry fetch: a network failure now returns the original
  409 response instead of escaping as a TypeError, and recovery
  failures are logged for observability.

Deliberately unchanged: Authorization headers are not re-resolved on
retry (the auth header source is caller-dependent — workspace vs user
scoped — and re-resolving risks swapping scopes; a stale token fails
safe into the caller's normal error path), and the shared recovery
promise has no timeout (bounded by the platform fetch timeout).

Adds tests: business-409 passthrough, non-customer-endpoint
passthrough, re-provisioning after auth state change, network-failure
retry fallback, concurrent credit pre-flight dedupe.
2026-06-17 11:49:30 -07:00
Matt Miller
57ca24d9f6 fix(cloud): self-heal missing customer record on 409 from /customers/* endpoints
Customer creation runs after the Firebase session is established during
sign-in/sign-up, so an interruption (navigation, closed window, network
failure) can leave a permanently signed-in user without a customer
record. Sessions restored from persisted credentials never re-run the
sign-in flow, so every /customers/* request (subscription status,
balance, checkout, billing portal) fails with 409 and nothing ever
retries the creation - affected users cannot subscribe at all.

Add a fetch wrapper in authStore that, on a 409 from a /customers/*
endpoint, provisions the customer record once (deduplicated across
concurrent callers, memoized per session on success) and retries the
original request a single time. If provisioning fails the original 409
is returned so callers keep their existing error handling. Wired into
all fetch-based /customers/* call sites.
2026-06-17 11:49:29 -07:00
Matt Miller
25cfca7bca Merge branch 'main' into ci/cursor-review-workflow 2026-06-17 11:26:20 -07:00
Matt Miller
8d23daa33d fix: resolve review feedback 2026-06-17 11:25:19 -07:00
GitHub Action
880582ab5d [automated] Apply ESLint and Oxfmt fixes 2026-06-17 18:21:48 +00:00
Matt Miller
e498c4ae0d ci: bump cursor-review SHA to github-workflows#9, drop judge_model override 2026-06-17 11:17:58 -07:00
Matt Miller
4cadbb8af9 fix: resolve review feedback 2026-06-15 15:42:26 -07:00
Matt Miller
cc048464fa ci: add team-gated Cursor review (thin caller for github-workflows)
Calls the reusable Comfy-Org/github-workflows cursor-review.yml (single source of truth for panel, judge, prompts, scripts) instead of a standalone copy. Label-gated to the team; secret-bearing jobs skip fork PRs. Judge overridden to Opus 4.8.
2026-06-15 14:36:26 -07:00
693 changed files with 11174 additions and 36848 deletions

86
.claude/settings.json Normal file
View File

@@ -0,0 +1,86 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -63,14 +63,3 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -1,197 +0,0 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -134,27 +134,6 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped \
--ignore-errors source,unmapped,range \
--synthesize-missing
- name: Upload HTML report artifact

View File

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

@@ -95,7 +95,6 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -55,3 +55,6 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -40,6 +40,3 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -67,15 +67,7 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -6,7 +6,6 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -18,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

@@ -32,13 +32,12 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,7 +21,6 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

View File

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

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

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

@@ -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,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -1,129 +0,0 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -1,41 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -1,193 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
const { hover = 'self', class: className } = defineProps<{
hover?: 'self' | 'group'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
hover === 'group'
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
className
)
"
aria-hidden="true"
>
<ChevronRight class="size-5" :stroke-width="2" />
</div>
</template>

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

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

@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp },
{ label: t('nav.supportedModels', locale), href: routes.models }
{ label: t('nav.mcpServer', locale), href: routes.mcp }
]
},
{
@@ -86,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}
@@ -175,7 +173,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
</div>
</footer>
</template>

View File

@@ -33,41 +33,36 @@ useHeroAnimation({
</script>
<template>
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

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

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

View File

@@ -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,31 +30,23 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.getStarted', locale),
label: t('cta.tryWorkflow', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: routes.mcp
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
}
},
columns: [
@@ -90,7 +82,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
href: routes.launches,
badge: 'new'
},
{ label: t('nav.supportedModels', locale), href: routes.models },
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -103,7 +94,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -26,10 +26,6 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -823,7 +819,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud - supported by default',
en: 'Custom-node packs on Comfy Cloud supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -932,9 +928,9 @@ const translations = {
'zh-CN': '所有模型。\n商业许可保证。'
},
'cloud.reason.2.description': {
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
'zh-CN':
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,10 +992,6 @@ const translations = {
en: 'Wan 2.2',
'zh-CN': 'Wan 2.2'
},
'cloud.aiModels.card.gptImage2': {
en: 'GPT Image 2',
'zh-CN': 'GPT Image 2'
},
'cloud.aiModels.ctaDesktop': {
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
'zh-CN': '探索最新模型工作流'
@@ -1845,8 +1837,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -1872,10 +1864,6 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1913,27 +1901,21 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '或手动添加'
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -2190,13 +2172,6 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -2208,7 +2183,6 @@ const translations = {
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -2222,16 +2196,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
},
'nav.featuredProductsAlt': {
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',
@@ -3483,8 +3457,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3503,429 +3477,6 @@ const translations = {
'zh-CN': '生效日期'
},
// ── Enterprise MSA ─────────────────────────────────────────────────
// English-only, by design. This is a legal-reviewed customer-facing
// template. Serving a translated variant would expose Comfy to
// liability from the translation diverging from the approved English
// source. See the matching header comment in
// src/pages/enterprise-msa.astro and the LOCALE_INVARIANT_ROUTE_KEYS
// entry in src/config/routes.ts.
'enterprise-msa.effective-date': {
en: 'May 22, 2026',
'zh-CN': 'May 22, 2026'
},
'enterprise-msa.1-definitions.label': {
en: 'DEFINITIONS',
'zh-CN': 'DEFINITIONS'
},
'enterprise-msa.1-definitions.title': {
en: '1. Definitions',
'zh-CN': '1. Definitions'
},
'enterprise-msa.1-definitions.block.0': {
en: '<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'enterprise-msa.1-definitions.block.1': {
en: '<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'enterprise-msa.1-definitions.block.2': {
en: '<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.',
'zh-CN':
'<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.'
},
'enterprise-msa.1-definitions.block.3': {
en: '<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'enterprise-msa.1-definitions.block.4': {
en: '<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'enterprise-msa.1-definitions.block.5': {
en: '<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'enterprise-msa.1-definitions.block.6': {
en: '<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.',
'zh-CN':
'<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.'
},
'enterprise-msa.1-definitions.block.7': {
en: '<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.',
'zh-CN':
'<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.'
},
'enterprise-msa.1-definitions.block.8': {
en: '<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'enterprise-msa.1-definitions.block.9': {
en: '<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'enterprise-msa.1-definitions.block.10': {
en: '<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'enterprise-msa.1-definitions.block.11': {
en: '<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.',
'zh-CN':
'<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.'
},
'enterprise-msa.1-definitions.block.12': {
en: '<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'enterprise-msa.2-comfy-products.label': {
en: 'PRODUCTS',
'zh-CN': 'PRODUCTS'
},
'enterprise-msa.2-comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'enterprise-msa.2-comfy-products.block.0': {
en: '<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.',
'zh-CN':
'<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.'
},
'enterprise-msa.2-comfy-products.block.1': {
en: '<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'enterprise-msa.2-comfy-products.block.2': {
en: '<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.3': {
en: '<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.'
},
'enterprise-msa.2-comfy-products.block.4': {
en: '<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'enterprise-msa.2-comfy-products.block.5': {
en: '<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.6': {
en: '<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.'
},
'enterprise-msa.3-customer-responsibilities.label': {
en: 'CUSTOMER',
'zh-CN': 'CUSTOMER'
},
'enterprise-msa.3-customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'enterprise-msa.3-customer-responsibilities.block.0': {
en: '<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.',
'zh-CN':
'<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.'
},
'enterprise-msa.3-customer-responsibilities.block.1': {
en: '<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'enterprise-msa.3-customer-responsibilities.block.2': {
en: '<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'enterprise-msa.4-payment.label': {
en: 'PAYMENT',
'zh-CN': 'PAYMENT'
},
'enterprise-msa.4-payment.title': {
en: '4. Payment',
'zh-CN': '4. Payment'
},
'enterprise-msa.4-payment.block.0': {
en: '<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.',
'zh-CN':
'<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.'
},
'enterprise-msa.4-payment.block.1': {
en: '<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'enterprise-msa.4-payment.block.2': {
en: '<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.',
'zh-CN':
'<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.'
},
'enterprise-msa.4-payment.block.3': {
en: '<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'enterprise-msa.5-term-termination.label': {
en: 'TERM',
'zh-CN': 'TERM'
},
'enterprise-msa.5-term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'enterprise-msa.5-term-termination.block.0': {
en: '<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.',
'zh-CN':
'<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.'
},
'enterprise-msa.5-term-termination.block.1': {
en: '<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.',
'zh-CN':
'<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.'
},
'enterprise-msa.5-term-termination.block.2': {
en: '<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.',
'zh-CN':
'<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.'
},
'enterprise-msa.5-term-termination.block.3': {
en: '<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'enterprise-msa.6-confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'enterprise-msa.6-confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'enterprise-msa.6-confidentiality.block.0': {
en: '<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'enterprise-msa.6-confidentiality.block.1': {
en: '<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'enterprise-msa.6-confidentiality.block.2': {
en: '<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'enterprise-msa.6-confidentiality.block.3': {
en: '<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'enterprise-msa.7-proprietary-rights.label': {
en: 'IP',
'zh-CN': 'IP'
},
'enterprise-msa.7-proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'enterprise-msa.7-proprietary-rights.block.0': {
en: '<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'enterprise-msa.7-proprietary-rights.block.1': {
en: '<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'enterprise-msa.7-proprietary-rights.block.2': {
en: '<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'enterprise-msa.8-warranties-disclaimer.label': {
en: 'WARRANTIES',
'zh-CN': 'WARRANTIES'
},
'enterprise-msa.8-warranties-disclaimer.title': {
en: '8. Warranties; Disclaimer',
'zh-CN': '8. Warranties; Disclaimer'
},
'enterprise-msa.8-warranties-disclaimer.block.0': {
en: '<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.',
'zh-CN':
'<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.'
},
'enterprise-msa.8-warranties-disclaimer.block.1': {
en: '<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.',
'zh-CN':
'<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.'
},
'enterprise-msa.8-warranties-disclaimer.block.2': {
en: '<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.',
'zh-CN':
'<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.'
},
'enterprise-msa.9-limitation-of-liability.label': {
en: 'LIABILITY',
'zh-CN': 'LIABILITY'
},
'enterprise-msa.9-limitation-of-liability.title': {
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'enterprise-msa.9-limitation-of-liability.block.0': {
en: 'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.',
'zh-CN':
'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.'
},
'enterprise-msa.10-indemnification.label': {
en: 'INDEMNITY',
'zh-CN': 'INDEMNITY'
},
'enterprise-msa.10-indemnification.title': {
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'enterprise-msa.10-indemnification.block.0': {
en: '<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.',
'zh-CN':
'<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.'
},
'enterprise-msa.10-indemnification.block.1': {
en: '<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.',
'zh-CN':
'<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.'
},
'enterprise-msa.11-miscellaneous.label': {
en: 'MISCELLANEOUS',
'zh-CN': 'MISCELLANEOUS'
},
'enterprise-msa.11-miscellaneous.title': {
en: '11. Miscellaneous',
'zh-CN': '11. Miscellaneous'
},
'enterprise-msa.11-miscellaneous.block.0': {
en: '<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.',
'zh-CN':
'<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.'
},
'enterprise-msa.11-miscellaneous.block.1': {
en: '<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.',
'zh-CN':
'<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.'
},
'enterprise-msa.11-miscellaneous.block.2': {
en: '<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'enterprise-msa.11-miscellaneous.block.3': {
en: '<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'enterprise-msa.11-miscellaneous.block.4': {
en: '<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'enterprise-msa.11-miscellaneous.block.5': {
en: '<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'enterprise-msa.11-miscellaneous.block.6': {
en: '<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'enterprise-msa.11-miscellaneous.block.7': {
en: '<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'enterprise-msa.11-miscellaneous.block.8': {
en: '<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.',
'zh-CN':
'<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.'
},
'enterprise-msa.11-miscellaneous.block.9': {
en: '<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.',
'zh-CN':
'<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.'
},
'enterprise-msa.11-miscellaneous.block.10': {
en: '<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'enterprise-msa.12-exhibit-a.label': {
en: 'EXHIBIT A',
'zh-CN': 'EXHIBIT A'
},
'enterprise-msa.12-exhibit-a.title': {
en: 'Exhibit A. Order Form',
'zh-CN': 'Exhibit A. Order Form'
},
'enterprise-msa.12-exhibit-a.block.0': {
en: 'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.',
'zh-CN':
'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.'
},
'enterprise-msa.12-exhibit-a.block.1': {
en: 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.',
'zh-CN':
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
'zh-CN':
'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.'
},
'enterprise-msa.page.heading': {
en: 'Enterprise Customer Agreement',
'zh-CN': 'Enterprise Customer Agreement'
},
'enterprise-msa.page.tocLabel': {
en: 'On this page',
'zh-CN': 'On this page'
},
'enterprise-msa.page.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'enterprise-msa.page.parties': {
en: 'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).',
'zh-CN':
'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).'
},
'footer.enterpriseMsa': {
en: 'Enterprise MSA',
'zh-CN': 'Enterprise MSA'
},
// Customers page
'customers.hero.label': {
en: 'CUSTOMER STORIES',
@@ -4068,6 +3619,7 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4163,6 +3715,10 @@ const translations = {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'
@@ -4361,8 +3917,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4387,8 +3943,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',
@@ -4418,12 +3974,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
},
'launches.banner.cta': {
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
en: 'Join livestream',
'zh-CN': '加入直播'
},
// Launches page (/launches) — closing CTA

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

@@ -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,29 +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,
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)]}
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",

View File

@@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -12,6 +13,7 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning - Comfy">
<BaseLayout title="Learning Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models - Comfy"
title="Models Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
})
const faqPage: JsonLdNode = {
url: canonicalURL.href,
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('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} - Comfy`}
title={`${pageTitle} Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,29 +2,10 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -43,15 +24,8 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

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