Compare commits

...

24 Commits

Author SHA1 Message Date
imick-io
ed4f7db7f4 feat(website): add affiliate program page at /affiliates (#12670)
## Summary

- Adds the Comfy affiliate program landing page at `/affiliates`,
composed from 8 reusable presentational blocks (`HeroSplit01`,
`ChecklistSplit01`, `StepsGrid01`, `BenefitsGrid01`, `DetailsTable01`,
`BrandAssetsGrid01`, `FAQSplit01`, `CtaCenter01`) and thin section
wrappers under `templates/affiliate/*`. The page funnels through: Hero →
Audience → How It Works → Why ComfyUI → Program Details → Brand Assets →
FAQ → closing "Ready to start earning?" CTA.
- Removes the older parallel implementation under
`components/affiliates/*` (7 Vue sections, 3 data files, the i18n
contract test) and the `affiliate-landing.*` translation keys — the page
now has a single canonical i18n surface under `affiliate.*`.
- Rewrites `e2e/affiliates.spec.ts` to target the new sections via
semantic queries (no testids were added to production), covering hero
indexability, the closing CTA's apply + terms links, FAQPage JSON-LD
entry count, and FAQ toggle behavior.
- Apply CTA links to the application form
(`forms.gle/RS8L2ttcuGap4Q1v6`, `target=_blank`); terms link goes to
`/affiliates/terms` (English-only by design, per the legal-reviewed
terms document).

## Test plan

- [ ] `pnpm --filter @comfyorg/website typecheck` passes (0 errors)
- [ ] `pnpm --filter @comfyorg/website test:unit` passes (96 tests
across 13 files)
- [ ] `pnpm --filter @comfyorg/website knip --cache` reports no new
orphans introduced by this branch
- [ ] Visit `/affiliates` — all 8 sections render in order on desktop
and mobile
- [ ] APPLY NOW (in closing CTA section) opens
`forms.gle/RS8L2ttcuGap4Q1v6` in a new tab
- [ ] "Read the affiliate program terms" link navigates to
`/affiliates/terms` in the same tab
- [ ] FAQ items expand/collapse on click; `<script
type="application/ld+json">` FAQPage entry contains all 8 Q/A pairs from
`src/data/affiliateFaq.ts`
- [ ] Run e2e: `pnpm --filter @comfyorg/website playwright test
e2e/affiliates.spec.ts`

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 20:34:43 +00:00
imick-io
39157f2375 Feat/models page (#12429)
## Summary

Add a new `/models` landing page (EN + zh-CN) for the marketing site,
plus supporting tweaks: a generator-side mechanism for old→new
model-slug 301 redirects, spacing/CTA polish on shared sections, and a
per-item layout option on GalleryCard.

## Changes

- **What**:
- New `/models` and `/zh-CN/models` pages composed of a hero (autoplay
video, modelName + i18n CTA), a creations gallery, and the shared
`AIModelsSection`.
- New `ModelsHeroSection.vue` and `ModelCreationsSection.vue`
components; localised strings under `models.list.*` / `models.hero.*` in
`translations.ts`.
- Reused `AIModelsSection` on the models page (replaces the duplicated
showcase markup that previously lived under `models/`) so the same
component now powers both the cloud product page and the models page.
- `generate-models.ts`: renamed the `grok` provider to **Grok Imagine**
(`grok-imagine` slug) and added a `LEGACY_SLUG_REDIRECTS` constant that
emits stub entries so the existing `canonicalSlug` mechanism in
`[slug].astro` issues a 301 from old slugs (`grok-image` →
`grok-imagine`).
- `model-metadata.ts`: renamed the metadata key from `grok-image` to
`grok-imagine` (hub slug unchanged).
- `GalleryCard.vue`: added per-item `objectFit` / `objectPosition`
overrides so individual gallery entries can opt out of the default
`cover` crop.
- `ModelsHeroSection.vue`: dropped the empty-string default on
`videoAriaLabel` and omit `aria-label` (with `aria-hidden="true"`) when
no label is provided — addresses the CodeRabbit accessibility note.
- Minor vertical-spacing tightening on `ModelCreationsSection` and the
shared `AIModelsSection`.
- **Breaking**: None. Old `/p/supported-models/grok-image` URLs 301 to
the new slug.
- **Dependencies**: None.

## Review Focus

- The `LEGACY_SLUG_REDIRECTS` constant in
`apps/website/scripts/generate-models.ts` is the new source of truth for
renamed slugs — future slug renames should follow the same pattern so
the redirect survives regeneration of `generated-models.json`.
- The shared `AIModelsSection` is now used in two places; confirm the
spacing tweak (`py-24 → py-16`, `mt-24 → mt-16 lg:mt-24`) still looks
correct on the cloud product page.
- `generated-models.json` is fully regenerated by `pnpm generate:models`
— diff size is large but mechanical.

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-09 20:14:03 +00:00
Matt Miller
47118ef64f fix(image): handle useImage load errors instead of reporting them as unhandled (#12729)
## ELI-5

When an image on the page fails to load — a broken thumbnail, an expired
share
link, a flaky in-app browser — the app already handles it and shows a
fallback.
But under the hood, the image helper was *also* shouting "uncaught
error!" to the
browser's global error channel every time. Our monitoring hears that
shout and
logs it as a crash. With enough broken images (some in-app browsers
retry in a
loop), it became the single loudest "error" in our telemetry — for
something
that isn't actually broken. This tells the helper to handle the failure
quietly
instead of shouting.

## What

`useImage()` (from `@vueuse/core`) exposes load failures via its `error`
ref,
which every call site here already uses to render a fallback. But
vueuse's
default `onError` forwards the error to `globalThis.reportError`, so
each failed
`<img>` load also surfaces as an **unhandled** error to global error
monitoring.

This makes failed image loads — 404'd thumbnails, expired share links,
in-app
webviews that re-fetch on a loop — the highest-volume unhandled frontend
error
in our production telemetry, despite being expected and already handled
in the UI.

## Fix

Pass an explicit `onError` (a documented no-op) as the `useAsyncState`
options
argument at all four `useImage()` call sites:

- `components/common/ComfyImage.vue`
- `platform/workflow/sharing/components/ShareAssetThumbnail.vue`
- `platform/assets/components/MediaImageTop.vue`
- `platform/assets/components/AssetCard.vue`

The `error` ref is still set by `useAsyncState` before `onError` runs,
so the
fallback-UI behaviour is unchanged — the only difference is we stop
re-reporting
handled failures to the global error handler.

## Test plan

- [x] No behavioural change to the `error` ref / fallback rendering
(verified
against vueuse `useImage`/`useAsyncState` semantics: `error.value` is
assigned
  independently of `onError`).
- [ ] CI lint/format/type checks.

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

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-09 18:36:21 +00:00
Matt Miller
f110af79f7 fix(widgetStore): tolerate null/undefined custom widgets from extensions (#12728)
## ELI-5

Some custom nodes have a `getCustomWidgets()` function that's *supposed*
to hand
us a list of widgets. A few of them hand us back nothing
(null/undefined)
instead. We were trying to read that "nothing" like a list, which
crashes with
*"Cannot convert undefined or null to object"* — and because it happens
while
the app is still starting up, it can break the whole page. This PR just
says
"if there's nothing to register, skip it."

## What

`registerCustomWidgets` called `Object.entries(newWidgets)` directly.
When an
extension's `getCustomWidgets()` resolves to `null`/`undefined` (it's
typed
non-null, but extensions are untrusted and routinely violate the type),
this
throws `TypeError: Cannot convert undefined or null to object`.

The call site in `extensionService.ts` runs this inside a bare async
IIFE,
*outside* the `wrapWithErrorHandling` wrappers used for
keybindings/settings, so
the throw is unhandled and surfaces during app initialization.

## Why it matters

In production this is one of the highest-volume unhandled frontend
errors —
~2.6k events across **~1,160 distinct sessions/day**, all funneling
through this
one `Object.entries` call. Guarding the choke point silences it for
every
caller.

## Fix

- Keep `registerCustomWidgets` typed `Record<string,
ComfyWidgetConstructor>`
(the correct internal contract) and early-return on nullish input. The
runtime
guard defends against untrusted extensions that violate the type at the
  boundary, without weakening the signature for legitimate callers.
- Add a regression test asserting
`registerCustomWidgets(null!/undefined!)` does
  not throw (the `!` casts simulate the boundary violation).

## Test plan

- [x] `npx vitest run src/stores/widgetStore.test.ts` — 8 passing,
including the
  new null/undefined case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 18:32:51 +00:00
Steven Tran
8972d27689 refactor(telemetry): route execution events to GTM only (MAR-282) (#12717)
## Summary
Client-side execution events (`execution_start` / `execution_success` /
`execution_error`) are now emitted only by the GTM provider, removing
the redundant Mixpanel and PostHog emissions that duplicated the
server-side PostHog execution pipeline.

## Changes
- **Removed** `trackWorkflowExecution`, `trackExecutionError`, and
`trackExecutionSuccess` from `MixpanelTelemetryProvider` and
`PostHogTelemetryProvider`, along with the now-unused
`lastTriggerSource` field and related type imports.
- **Kept** these methods on `GtmTelemetryProvider`. The
`TelemetryProvider` interface declares them optional and
`TelemetryRegistry` dispatches via optional chaining, so callers are
unchanged and Mixpanel/PostHog simply receive nothing for these events.
- **Added** GTM unit tests for `execution_start` and `execution_success`
(alongside the existing `execution_error` test) to pin the remaining
client-side path.

## Review Focus
- Execution telemetry on the client now flows exclusively to GTM;
PostHog execution data is expected to come solely from the server side,
so there should be no double-counting.
- The server-side PostHog execution pipeline is out of scope for this
frontend change — this PR only stops the client from emitting duplicate
execution events.

Reference: MAR-282
Prior context: Comfy-Org PR #3423.

Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-09 17:26:34 +00:00
jaeone94
72d1261983 [bugfix] Use Desktop2 bridge for missing model downloads (#12710)
## Summary

Fixes the Desktop2 missing-model download path so the frontend calls the
Desktop2 download bridge directly when it is available, instead of
relying on the browser `<a download>` fallback that Desktop2 currently
has to intercept indirectly.

This addresses Linear FE-956, where missing-model downloads on Windows
could open the OS Save As dialog. The issue was reproducible when the
frontend language was not English: switching the UI language back to
English made the download succeed again.

## Root Cause

Desktop2 currently has compatibility logic that watches/intercepts the
frontend missing-model download flow from outside the FE code. That
interception depends on FE-rendered DOM details, including localized
accessible labels such as the missing-model download button
`aria-label`.

In English, Desktop2 could find the expected download controls and cache
the missing-model metadata before the FE-created `<a>` download was
clicked. In non-English locales, the localized label no longer matched
Desktop2's selector, so the Desktop2 interception path missed the
download. The FE then continued down the browser download path, which
Electron surfaced as a native Save As dialog on Windows.

## Changes

- Adds a narrow Desktop2 runtime bridge check in
`missingModelDownload.ts`:
  - if `window.__comfyDesktop2.downloadModel` exists
  - and `window.__comfyDesktop2Remote` is not set
- then FE calls `window.__comfyDesktop2.downloadModel(model.url,
model.name, model.directory)` directly and returns early.
- Keeps remote Desktop2 sessions on the existing browser fallback path
by preserving the `__comfyDesktop2Remote` guard.
- Leaves the existing OSS browser fallback and legacy desktop
`isDesktop` download-store path intact.
- Logs Desktop2 bridge failures so rejected promises or synchronous
bridge throws do not become unhandled errors.
- Adds regression coverage for:
- Desktop2 bridge path taking priority over browser and legacy desktop
fallbacks.
- rejected Desktop2 bridge calls being logged without falling back to
browser download.
- synchronously thrown Desktop2 bridge failures being logged without
crashing or falling back to browser download.
  - remote Desktop2 sessions continuing to use browser fallback.

## User Impact

Desktop2 users should no longer depend on localized FE DOM text for
missing-model downloads. In particular, non-English UI locales should
route missing-model downloads through Desktop2's managed downloader
instead of opening the OS Save As dialog.

## Validation

- Manually verified the issue is fixed in Desktop2 using a locally built
FE dist served through ComfyUI with `--front-end-root`.
- Verified Korean locale no longer triggers the Save As dialog and the
missing-model download succeeds through Desktop2.
- Verified the new regression test fails when the production bridge fix
is reverted.
- Covered the FE-side contract with unit tests because a true end-to-end
assertion of the Windows native Save As dialog is not currently
practical in the FE browser-test infrastructure. The FE tests can verify
that clicking missing-model download routes into
`window.__comfyDesktop2.downloadModel`; they cannot directly prove
Electron/Windows native dialog behavior. That full native-dialog
regression belongs in Desktop2/Electron integration coverage.
- Ran:
- `pnpm exec oxfmt --check
src/platform/missingModel/missingModelDownload.ts
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm lint:unstaged`
- `pnpm exec vitest run
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm typecheck`
  - `pnpm build`
- Pre-commit hook passed: `oxfmt`, `oxlint`, `eslint`, `typecheck`.
- Pre-push hook passed: `knip --cache` completed with existing tag hints
only.
- Ran a 3-round local Claude review loop; final verdict was approve with
no Blocker/Major findings.

## Follow-up Work

- Define and document the FE/Desktop2 bridge contract explicitly,
including the expected semantics of `downloadModel` resolving `false`
versus rejecting.
- Add a shared or canonical TypeScript declaration for
`window.__comfyDesktop2` and `window.__comfyDesktop2Remote` if more FE
code starts depending on these globals.
- Remove Desktop2's DOM/aria/class-based missing-model download
interception after a sufficient FE compatibility window, so Desktop2 no
longer depends on FE DOM structure or localized labels.
- Add Desktop2 integration/e2e coverage for missing-model downloads in
non-English locales, ideally including Windows where the Save As dialog
was observed. This is the right layer for a true native Save As
regression test.
- Optionally add a lighter FE browser E2E that injects a fake
`window.__comfyDesktop2.downloadModel` and verifies the missing-model UI
calls that bridge. This would validate the FE contract, but it would
still not replace Desktop2/Electron coverage for native dialog behavior.
- Decide on user-facing failure UX for Desktop2 bridge download failures
once Desktop2 defines whether failures, cancellations, and
already-queued downloads are represented by rejection or by `false`.

## Notes

This intentionally does not fall back to browser download when the
Desktop2 bridge resolves `false`. Falling back there could reintroduce
the exact Save As dialog behavior this PR fixes, and the meaning of
`false` should be clarified in the Desktop2 bridge contract before FE
invents user-facing behavior for it.

A true E2E test for this bug would need to exercise Desktop2/Electron on
Windows and assert that the native Save As dialog is not opened. The
current FE browser-test infrastructure cannot observe that native
Desktop2 behavior directly, so this PR uses focused unit regression
coverage for the FE routing contract plus manual Desktop2 verification.
2026-06-09 16:42:19 +00:00
jaeone94
1b90696459 Simplify missing media error presentation (#12705)
## Summary

This is the second PR in the planned Workflow Overview error-tab
modernization stack. It follows the merged execution-error slice in
#12683 and focuses only on Missing Media / Missing Inputs.

The intent is to simplify missing-media errors from
media-type/file-resolution cards into compact node-level guidance.
Missing media is now presented as a flat list of affected node inputs,
matching the new catalog-driven item-row direction introduced by the
first PR while intentionally avoiding the larger missing-node,
swap-node, and missing-model redesigns.

This PR also removes the upload/library selection workflow from the
error tab. Missing media rows now help the user locate the affected
node/input directly, rather than attempting to resolve the missing file
inside the error card.

## Changes

- **What**: Simplifies the Missing Inputs card into a flat list of
affected node/widget rows.
- **What**: Displays each missing media row with a catalog-resolved item
label in the form `{nodeName} - {inputName}`.
- **What**: Makes both the row label and right-aligned locate icon
navigate to the affected node on canvas.
- **What**: Adds item-specific accessible labels for locate icon buttons
and an explicit focus-visible state for clickable row labels.
- **What**: Uses the existing `list-scale` transition pattern so missing
media rows animate consistently when they enter or leave.
- **What**: Removes the old image/video/audio sub-sections from the
missing-media card.
- **What**: Removes missing-media
upload/dropzone/library-selection/status/confirm/cancel UI, because
resolution is no longer handled inside this card.
- **What**: Removes stale E2E selectors and tests tied to the deleted
upload/library controls.
- **What**: Removes stale English missing-media locale keys for the
deleted controls; translated locale files are intentionally left to the
localization sync process.
- **What**: Removes stale missing-media store interaction state
(`expandState`, `uploadState`, `pendingSelection`) and the now-unused
name-based removal API that only supported the deleted upload/library
flow.
- **What**: Keeps the remaining widget/node/prefix candidate-removal
APIs used by the missing-media lifecycle.
- **What**: Carries `nodeType` through missing-media referencing nodes
so fallback labels remain correct when the same missing filename is
referenced by multiple node types.
- **Breaking**: None expected. This changes the missing-media error-tab
UX, but does not change the workflow schema or backend APIs.
- **Dependencies**: None.

## Planned stack

This PR is part of the same staged redesign plan described in #12683.
The goal is to keep each error category reviewable and stable before
moving to the next slice.

1. Execution errors, merged in #12683: validation, runtime, and prompt
error grouping/presentation.
2. Missing media, this PR: simplify image/video/audio missing-media
cards into node-level catalog item rows with locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

This PR intentionally does not change missing node packs, swap nodes,
missing models, runtime errors, validation errors, or prompt errors.

## Review Focus

- Missing media rows should now be node-level rows, not file-level rows
and not image/video/audio sections.
- If the same missing media filename is referenced by multiple nodes,
each affected node/input should render as its own row.
- Clicking either the row label or locate icon should focus the
corresponding affected node.
- Removed upload/library controls are intentional and should not be
restored in this PR.
- The missing-media store cleanup should not remove any state or API
still used by the remaining missing-media lifecycle.
- Only `rightSidePanel.missingMedia.missingMediaTitle` should remain in
the English locale for the old missing-media UI namespace; new row copy
lives in the error catalog.
- This PR should stay scoped to Missing Media / Missing Inputs and
should not include the larger missing model/node/swap redesign work.

## Screenshots (if applicable)

This PR
<img width="483" height="339" alt="스크린샷 2026-06-08 오후 8 26 43"
src="https://github.com/user-attachments/assets/e029411e-b1db-41cd-aa6e-2bd5d86762f6"
/>

Before (Main)
<img width="484" height="802" alt="스크린샷 2026-06-08 오후 8 32 19"
src="https://github.com/user-attachments/assets/c183c18d-babe-4f4e-b4e0-706ce41298de"
/>

## Validation

- `pnpm format`
- `pnpm test:unit src/platform/missingMedia/missingMediaStore.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts`
- `pnpm typecheck`
- `pnpm knip --cache`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts
--project=chromium`
- Pre-push hook: `pnpm knip --cache`

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
Comfy Org PR Bot
e340b5b127 1.46.11 (#12722)
Patch version increment to 1.46.11

**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-09 05:56:12 +00:00
Deep Mehta
68238a5742 fix(website): show Windows + Mac fallback on /download when UA detection fails (#12727)
## What
The /download hero now surfaces both Windows AND macOS download buttons
when the post-mount UA sniff comes back empty (Linux desktop, mobile,
privacy-stripped UAs). Today those users see only the GitHub-install
button — no actual download link.

## Why
Field complaint: \"I can't find a download link at all on this page.\"
Reproduced on Chrome with a Linux UA — \`useDownloadUrl\` returns
\`platform: null\`, and \`<DownloadLocalButton v-show=\"platform\">\`
collapses to \`display: none\`. The user only sees \"INSTALL FROM
GITHUB\" next to it, with no obvious way to grab the Windows or Mac dmg.

Showing both is the better fallback — they're the two artifacts we
actually ship today (\`downloadUrls.windows\`, \`downloadUrls.macArm\`),
the UA path already encodes them, and the buttons exist as fragment
siblings so they slot into the parent's flex container next to the
existing GitHub button without extra layout wrapping.

## How
- \`useDownloadUrl\` now exposes a \`detected\` ref that flips to
\`true\` post-mount regardless of whether UA matched. Lets the component
distinguish three states:
- **\`!detected\`** → pre-hydration, render nothing (avoids SSR flicker)
  - **\`detected && platform\`** → single matched CTA, current behaviour
  - **\`detected && !platform\`** → NEW: render both Windows AND Mac
- \`DownloadLocalButton\` adds the fallback branch as a \`<template
v-else-if>\` fragment so the two buttons slot directly into the parent's
existing \`flex-col lg:flex-row\` layout next to the GitHub button. Each
carries its OS icon and an \`aria-label\` combining the existing
\"DOWNLOAD DESKTOP\" copy with the OS name so screen readers get equal
context to the detected-OS path.
- \`downloadUrls\` is now re-exported from the composable so the
fallback path uses the same source of truth instead of duplicating the
URLs.

## Tests
- [x] New \`@smoke\` e2e test (\`HeroSection falls back to both Windows
+ Mac when UA is unrecognized\`) — uses Linux UA, asserts both fallback
buttons appear with the right hrefs / target=\"_blank\", and confirms
the auto-detected single CTA is NOT also present
- [x] Existing Windows-UA test (\`HeroSection has download and GitHub
buttons\`) still passes — single CTA branch unchanged
- [ ] Manual: load /download on Linux Chrome → both buttons visible
- [ ] Manual: load /download on Windows → still single \"DOWNLOAD
DESKTOP\" button (no regression)
- [ ] Manual: load /download on macOS → still single \"DOWNLOAD
DESKTOP\" button with Apple icon
- [ ] Manual: load /download on mobile (which detection treats as null)
→ both fallback buttons visible
2026-06-09 05:50:48 +00:00
Comfy Org PR Bot
183a75a218 [chore] Update Ingest API types from cloud@ca12913 (#12726)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: ca12913
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 04:39:50 +00:00
Dante
507e667795 feat: register unified_cloud_auth feature flag (FE-950) - step 1 (#12702)
## Summary
https://us.posthog.com/project/204330/feature_flags/706651 

Register the `unified_cloud_auth` server feature flag (default **off**)
so the upcoming single Cloud-JWT auth provider (FE-950/FE-951) can be
gated behind it. Pure plumbing — no behavior change.

## Changes

- **What**: Adds `ServerFeatureFlag.UNIFIED_CLOUD_AUTH`, the
`unifiedCloudAuthEnabled` getter in `useFeatureFlags` (via
`resolveFlag`, precedence: dev override > `remoteConfig` > server
feature), and the `unified_cloud_auth?: boolean` field on the
`RemoteConfig` type. Unit tests cover the server-feature wiring and the
dev-override toggle.
- This is PR 1 of 3 for FE-950. Nothing reads the flag yet; it resolves
`false` by default, so this is inert in every environment until a later
PR wires consumers.

## Review Focus

- Mirrors the existing `resolveFlag`-based flags (e.g.
`userSecretsEnabled`, `workflowSharingEnabled`) — deliberately the plain
shape, **not** the cloud-gated `teamWorkspacesEnabled` shape, since the
flag is only read from paths already `isCloud`-gated.
- Local toggle for development:
`localStorage.setItem('ff:unified_cloud_auth', 'true')` (DEV-only dev
override).
- Production delivery of the flag value (and staged % rollout) is
backend work — Comfy-Org/cloud#4139 (BE-1258), already adds
`unified_cloud_auth` to `/api/features`. This FE PR is independent of
it; with no server value the flag stays `false`.

Linear: FE-950

## Screenshots (if applicable)

N/A — no user-facing change (flag registration only).
2026-06-09 02:15:33 +00:00
Comfy Org PR Bot
d617ab1c95 [chore] Update Ingest API types from cloud@1dc7e7e (#12227)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 1dc7e7e
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 01:29:43 +00:00
Dante
b93338cac7 fix(keybindings): progressively truncate keybinding column at narrow widths (#12181)
## Summary
- Progressive container-query truncation for the **Keybinding** column
in the Settings → Keybinding panel so the **actions column stops getting
clipped** at narrow widths.
- Tiers (all driven by `@container/keybindings` on the cell content — no
JS resize listeners):
  - `>= 16rem`: `Ctrl S , Ctrl Shift S + 1 more` (2 combos + `N-2 more`)
  - `>= 12rem`: `Ctrl S + 2 more` (1 combo + `N-1 more`)
  - `>= 8rem`: `Ctrl S + 2` (1 combo + compact `+N`)
  - smallest: first combo only
- Actions column pinned with `whitespace-nowrap` so it always stays
fully visible — the keybindings column compresses first, not the icons.
- New `KeybindingList.vue` extracted from `KeybindingPanel.vue`; the
prior inline rendering was inlined twice (column body + we'd need it
elsewhere if expansion logic changes).
- Adds `g.nMoreKeybindingsCompact` and `g.keybindingListAriaLabel` i18n
keys (en only; other locales fall back to en).

## Before / after

### before


https://github.com/user-attachments/assets/227cdd6a-fd5d-41ba-8af2-2a49e2d9cf15

### after
<img width="936" height="812" alt="Screenshot 2026-05-13 at 11 05 12 AM"
src="https://github.com/user-attachments/assets/f750efd8-7d14-4a46-a92d-fa67e57c7909"
/>








<!-- Drag the PNGs from ~/Desktop/fe-523-screenshots/ into this section.
Recommended order: wide → 16rem → 12rem → 8rem, before then after at
each tier.
Captured by temp/scripts/capture-fe523-screenshots.ts at 4 dialog
widths. -->

_screenshots pending — drag/drop from `~/Desktop/fe-523-screenshots/`_

The narrow tier shows the original FE-523 bug clearly: on `main` the
Source and Actions columns are clipped without a scrollbar; with this PR
the keybinding column compresses first, keeping at least the edit icon
visible. Some action icons still get clipped at the absolute narrowest
tier — full sticky-actions behavior is a follow-up.

## Why draft
Manual visual verification of the four container-query tiers on a real
backend still needs to happen — the snapshot script runs against a
stubbed-out backend, so the dataset is sparse (the multi-combo rows
aren't injected). Moving out of draft after that pass.

## References
- Fixes
[FE-523](https://linear.app/comfyorg/issue/FE-523/update-keybinding-panel-responsiveness)
- Figma proposal:
https://www.figma.com/design/ZNLRans6JLM3hvCz5LlCQA/Keyboard-Shortcut?node-id=625-17286
- Slack thread:
https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1776818615078629?thread_ts=1776808927.654249&cid=C075ANWQ8KS

## Test plan
- [x] `pnpm test:unit` — new `KeybindingList.test.ts` covers all 4 tiers
(0/1/2/3+ keybindings, count math `N-2` vs `N-1`, container class
present, aria-label assembled).
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] Snapshot diff at 4 widths (see Before / after section above once
images are attached).
- [ ] Manual on a real backend: command with 1 / 2 / 3 / 5 keybindings
each renders the correct `+N more` / `+N` value at each tier.
- [ ] Manual: confirm whether residual actions-column clipping at the
absolute narrowest tier warrants a follow-up.
2026-06-08 23:26:13 +00:00
Deep Mehta
fa2d187c83 feat(website): add Desktop privacy policy at /privacy/desktop (#12714)
## Summary

Publishes the Comfy Desktop privacy policy (currently bundled in-app
only) as a public page at \`/privacy/desktop\`, so users can read what
they consented to without re-opening the app and so the document is
verifiable / link-shareable. Also adds a single-sentence link from the
existing \`/privacy-policy\` page so people who land on the org-wide
policy can find the surface-specific one.

## Changes

- **What**: New marketing-site page at \`/privacy/desktop\` (en) and
\`/zh-CN/privacy/desktop\` (zh), backed by ~50 new \`desktop_privacy.*\`
i18n keys mirroring the in-app document. One-sentence addition to
\`privacy.intro.block.3\` linking to the new page.
- **Breaking**: None.
- **Dependencies**: None.

## Why now

Recent Discord audit by a user (cited Civitai as precedent) identified
six legitimate gaps between our website privacy policy and what GDPR /
UK GDPR Art 13 requires (named processors, lawful basis mapping,
retention specifics, international transfer mechanism, etc.). The
**in-app Desktop policy already addresses all of them**, but it isn't
publicly visible. This PR makes it visible at a URL.

A separate, counsel-reviewed PR will be needed to bring the org-wide
\`/privacy-policy\` page up to the same standard — that's deliberately
not in scope here.

## Content the new page covers

Mirrors the Desktop in-app document (effective 2026-05-19):

- Named processors: PostHog, Datadog, ToDesktop, Comfy Org analytics
warehouse
- Article 6 mapping: 6(1)(a) consent for analytics + crash reports;
6(1)(f) legitimate interests for update server
- Retention: 24 months analytics / 15 days crash / 36 months aggregate /
90 days update logs / removed on uninstall
- International transfers: Standard Contractual Clauses where required
- Data subject rights + \`privacy@comfy.org\` as contact
- Right to lodge complaint with ICO / EU supervisory authority / CPPA

## Review Focus

1. **i18n zh-CN placeholders.** Following the existing convention in
this file (see e.g. \`affiliate-landing.footerCta\`), zh-CN values use
the English text as placeholders. A follow-up PR will land Chinese
translations. Flag if there's a different preferred convention.
2. **Source-of-truth coupling.** This page is a publish of the bundled
in-app policy at \`src/renderer/src/lib/legalDocs.ts\`
(\`PRIVACY_POLICY\` constant) in the \`Comfy-Desktop\` repo. There's no
automated sync — the two need to stay aligned manually when the in-app
policy is revised. Worth a quarterly review reminder.
3. **\`privacy@comfy.org\` mailbox.** The policy directs all data
subject rights requests there. Confirm this inbox exists and is
monitored before merge.
4. **No \`noindex\` on the new page.** Unlike the existing
\`/privacy-policy\` page, this one is intentionally indexable — the
point is that it should be discoverable.

## Test plan

- [x] \`pnpm typecheck\` clean (0 errors, 0 warnings)
- [x] \`pnpm build\` succeeds; both \`/privacy/desktop/index.html\` and
\`/zh-CN/privacy/desktop/index.html\` are emitted
- [x] All 385 site pages still build
- [ ] After preview deploy: verify the link from \`/privacy-policy\` to
\`/privacy/desktop\` renders correctly, and the page table-of-contents
nav works (\`ContentSection\` infers sections from i18n keys)

## Companion items (not in this PR)

- Update \`comfy.org/privacy-policy\` (the org-wide policy) with named
processors, Art 6 mapping, retention specifics, and SCC language —
requires privacy counsel review
- Confirm Comfy Org Inc ICO registration status
- One-page determination from privacy counsel on whether Art 37 requires
a formal DPO (pseudonymous product telemetry is likely below threshold,
but the question deserves a documented answer)
2026-06-08 23:22:43 +00:00
bertfy
6f22ca11c3 feat(website): rename "Local" CTAs/branding to "Desktop" (#12689)
*PR Created by the Glary-Bot Agent*

---

## Summary

Renames the user-facing "Local" copy to "Desktop" across the comfy.org
marketing site (English + Chinese). Covers three surfaces requested in
sequence by the user:

1. "Download Local" CTA → "Download Desktop" (top nav, mobile menu,
homepage Get-started section, download page hero button).
2. "Comfy Local" nav-dropdown / footer label → "Comfy Desktop".
3. The yellow `LOCAL` badge in the `/download` hero → `DESKTOP`.

## Changes

- **i18n** (`apps/website/src/i18n/translations.ts`)
- `getStarted.step1.downloadLocal`, `download.hero.downloadLocal`,
`nav.downloadLocal`: en `Local` → `Desktop`, zh-CN `本地版` → `桌面版`.
- `nav.comfyLocal`: en `Comfy Local` → `Comfy Desktop`, zh-CN `Comfy
本地版` → `Comfy 桌面版`.
- **Nav animated CTA** (`SiteNav.vue`): hardcoded `core: 'LOCAL'` →
`core: 'DESKTOP'` (the visible text in the desktop nav, which is
separate from the i18n aria-label).
- **Download hero badge** (`product/local/HeroSection.vue`): passes
`text="DESKTOP"` to `<ProductHeroBadge />`, matching how the
Cloud/API/Enterprise heroes already specify their own text.
- **Shared badge default** (`common/ProductHeroBadge.vue`): default
`text` changed from `'LOCAL'` to `'DESKTOP'` so future callers do not
inherit retired branding.
- **E2E tests** updated to the new strings (navigation, homepage,
download specs).

## Out of scope (intentionally unchanged)

- The internal product-family slug `local` (Vue component file names
like `DownloadLocalButton.vue`, directory `components/product/local/`,
translation key `comfyLocal`, route `/download`). Renaming these is
internal restructuring that would balloon the diff with no user-visible
benefit and was not requested.
- The homepage products-card strings `products.local.title` ("Comfy
Local") and `products.local.cta` ("SEE LOCAL FEATURES"). These also live
on the homepage and link to `/download`; the user has been driving this
rename surface-by-surface and has not flagged this card yet. Happy to
extend in a follow-up if desired.
- Other "local" occurrences (e.g. "本地运行" in the zh-CN download FAQ, "Run
on your hardware", etc.) — those describe the deployment model, not the
product brand.

## Verification

- `pnpm --filter @comfyorg/website typecheck`: 0 errors
- `pnpm exec oxlint` on every touched file: 0 errors, 0 warnings
- `pnpm exec oxfmt --check` on every touched file: clean
- Husky `lint-staged` (typecheck + lint + format) ran green on each
commit
- Manual browser verification with Playwright at desktop (1440×900) and
mobile (390×844) viewports on `/`, `/download`, `/zh-CN/`,
`/zh-CN/download`:
  - Top nav CTA reads `DOWNLOAD DESKTOP` (aria-label `下载桌面版` on zh-CN)
  - Mobile menu CTA reads `DOWNLOAD DESKTOP`
  - Homepage Get-started section reads `Download Desktop`
  - Products dropdown shows `Comfy Desktop` as the first item
  - `/download` hero badge reads `Comfy DESKTOP` (en + zh-CN)
- DOM `innerText` contains 0 occurrences of `LOCAL`/`下载本地版`/`Comfy 本地版`

## Screenshots

Attached: desktop nav, homepage Get-started section, download hero badge
(en + zh-CN), mobile menu.

## Screenshots

![Desktop nav showing DOWNLOAD DESKTOP
CTA](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107228-7f86d778-c1fd-4eff-89eb-857c64760bf2.png)

![Homepage Get started section showing Download Desktop
link](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107536-4e7a8d15-57d7-4a97-b3dc-414f86c09e71.png)

![Download page hero with Comfy DESKTOP yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107833-b80a479e-18a5-45c7-92b9-a41dcd82741f.png)

![zh-CN download page hero with Comfy DESKTOP
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875108144-028f680d-f949-4960-9424-8c8096023222.png)

![Mobile menu showing DOWNLOAD DESKTOP
CTA](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875108481-e4a586ba-1b65-4077-b02c-517252f92255.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-06-08 20:23:11 +00:00
Robin Huang
c17f91095a feat: track search keystrokes across 5 surfaces (#12618)
## Summary

Adds a single `app:search_keystroke` PostHog event capturing the
debounced search query across five surfaces (`node_search_modal`,
`node_sidebar`, `apps_sidebar`, `template_search`, `settings_search`).
Today four of these emit nothing; the fifth (node search modal) fires
the disabled `app:node_search`.

## Changes

- **What**: Single composable `useSearchKeystrokeTracking(surface,
queryRef)` wired into each search input. 500ms debounce, trims and skips
empty queries.
- One event with a `surface` property (not 5 events) — matches the
existing `app:share_flow` precedent and lets PostHog break down across
surfaces uniformly.
- Replaces the disabled `app:node_search` call in `NodeSearchBox.vue`
(V1 modal) and wires the V2 modal at
`searchbox/v2/NodeSearchContent.vue`.

## Review Focus

- Five wire-ups, all single-line additions next to each `searchQuery`
ref.
- New event `app:search_keystroke` lands ready to query in PostHog with
`surface`, `query`, `query_length`.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 17:33:41 +00:00
Comfy Org PR Bot
b67d77f08a 1.46.10 (#12660)
Patch version increment to 1.46.10

**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-08 17:31:43 +00:00
AustinMroz
d7f0d75efd Support fast disco on subgraph IO (#12619)
Because subgraph links don't follow the normal link logic, the fast
disconnection circle would not function when dragging input links that
originate from a subgraph IO node. There's some sad duplication of
state, but I think the flow is marginally cleaner now by not requiring
`instanceof` checks.

The test added by this PR also verifies behavior for the node-to-node
case.
2026-06-08 16:07:34 +00:00
jaeone94
74b9f16b62 Refine execution error presentation (#12683)
## Summary

This is the first PR in a planned stack to modernize the Workflow
Overview error tab. It focuses only on execution-style errors:
validation errors, runtime errors, and known prompt errors.

The intent is to establish the catalog-driven presentation model before
touching the larger missing-resource cards. Validation and prompt errors
are known product states, so this PR makes them read more like
structured guidance instead of generic reportable failures. Runtime
errors remain reportable, but their details are reorganized so the error
log is easier to scan and copy.

## What changed

- Groups validation errors by error catalog id instead of node
class/type.
- Adds an `unknown_validation_error` fallback catalog id so validation
grouping can follow one rule without special-case missing catalog ids.
- Shows validation group title and message once, then lists each
affected input as a compact item row.
- Adds per-item validation detail disclosure so detailed validation text
is still available without repeating the group title/message for every
item.
- Keeps locate-node behavior available from validation rows, including
keyboard/ARIA disclosure wiring.
- Removes GitHub, copy, and help actions from validation/prompt errors
because these are known, cataloged errors where the UI copy should guide
the user directly.
- Refines runtime error cards so the error log is visible by default,
has its own header, and keeps copy/report actions inside the log area.
- Removes the special full-panel singleton runtime layout so runtime
errors keep the same fixed card rhythm as the other error groups.
- Keeps runtime errors reportable via Get Help and Find on GitHub,
because these can still represent unexpected execution failures.
- Updates prompt error detail styling to match the darker runtime
error-log treatment.
- Restores display-message semantics for grouped execution messages:
`displayMessage ?? message` is used for user-facing dedupe instead of
raw backend-only messages.
- Adds focused unit coverage for catalog grouping, fallback validation
catalog ids, display-message grouping, runtime detail behavior, and the
updated prompt/validation action model.

## Planned stack

This PR intentionally keeps the first slice narrow. The broader redesign
is planned as a sequence of follow-up PRs rather than one large change:

1. Execution errors, this PR: validation, runtime, and prompt error
grouping/presentation.
2. Missing media: simplify image/video/audio missing-media cards around
catalog item labels and locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

The goal is to review and stabilize each slice before stacking the next
one. This is especially important because later missing-model changes
are much larger and should not obscure the catalog/error-card behavior
introduced here.

## Review focus

- Validation errors should now group by catalog id, not by node class.
- Validation groups intentionally show one message per group, with
individual affected inputs rendered as rows.
- Prompt and validation errors intentionally no longer show
report/copy/help actions.
- Runtime errors intentionally still show report actions, but only
inside the error-log panel.
- Node id badges are intentionally not shown in these execution error
rows; the follow-up missing-resource PRs will handle their own row
treatments separately.
- This PR does not change missing media, missing model, missing node
pack, or swap node cards.

## Screenshots  
### This PR 

Validation error
<img width="457" height="362" alt="스크린샷 2026-06-07 오전 4 26 19"
src="https://github.com/user-attachments/assets/4c35b9f3-57dd-4dae-b44a-6d2fd8547b7c"
/>

Runtime error
<img width="454" height="545" alt="스크린샷 2026-06-07 오전 4 24 24"
src="https://github.com/user-attachments/assets/b7d4482f-b35b-4ed2-90f2-0a62dafa3519"
/>

Prompt / Service error 
<img width="456" height="192" alt="스크린샷 2026-06-07 오전 4 27 58"
src="https://github.com/user-attachments/assets/aeec0978-b47f-40c7-ab71-0a0d18ceb054"
/>


### Old (main)
Validation error
<img width="457" height="853" alt="스크린샷 2026-06-07 오전 4 25 09"
src="https://github.com/user-attachments/assets/185dd573-430d-4041-8b31-a8eb6346f1ff"
/>

Runtime error
<img width="455" height="554" alt="스크린샷 2026-06-07 오전 4 24 58"
src="https://github.com/user-attachments/assets/deb1c09d-ea58-4d6a-9ac6-d2a3a9832fbe"
/>

Prompt / Service error
<img width="455" height="297" alt="스크린샷 2026-06-07 오전 4 28 14"
src="https://github.com/user-attachments/assets/c68eef7c-6525-4a5b-858c-6482fe76ad27"
/>





## Validation

- `pnpm format:check`
- `pnpm test:unit src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `pnpm build`
2026-06-08 06:52:57 +00:00
jaeone94
dbeb9cc10d fix: clear missing model on promoted widget change (#12677)
## Summary

Fixes FE-942 by clearing missing model indicators when promoted subgraph
widgets are changed through the legacy canvas path.

## Changes

- **What**: Resolves promoted widget error-clearing targets in
`useErrorClearingHooks`, including legacy canvas path events from
interior widgets.
- **Dependencies**: None.

## Review Focus

- Promoted widget validation errors clear by resolved interior widget
name, while missing model/media state clears by source widget name.
- Same-named promoted widgets are value-gated so changing one promoted
model widget does not clear unchanged sibling indicators.
- Core promoted widget event emission remains unchanged; the fix is
scoped to the error-clearing hook.

## Validation

- `pnpm test:unit src/composables/graph/useErrorClearingHooks.test.ts`
- `pnpm test:unit src/core/graph/subgraph/promotedWidgetView.test.ts
src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts`
- `pnpm exec oxfmt --check
src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm exec oxlint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts --type-aware`
- `pnpm exec eslint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm typecheck`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep Subgraph`
- pre-push `knip --cache`

## Screenshots (if applicable)

N/A
2026-06-08 06:49:10 +00:00
Dante
49a05f32ca fix: stop Add Secret dialog rendering behind Settings modal (FE-939) (#12665)
## Summary

The "Add Secret" dialog (Settings → Secrets) opens but renders
**behind** the Settings modal because it never joins the shared dialog
z-index stack. This is the intermittent "dialog doesn't show" report in
FE-939.

## Changes

- **What**: Apply the existing `v-reka-z-index` directive to
`SecretFormDialog`'s `DialogOverlay` and `DialogContent`, mirroring
`GlobalDialog.vue`. This registers the dialog with the shared
`@primeuix` `ZIndex` `'modal'` sequence instead of relying on the static
`z-1700` Tailwind class, which always lost to the Settings modal once
the shared counter climbed past 1700.

## Review Focus

Root cause: `@primeuix` `ZIndex.set` runs with `autoZIndex=true`, so
every registered modal lands above the last in the shared `'modal'`
sequence — "whichever dialog opens last wins." The Settings modal
registers in that sequence (via `GlobalDialog`'s `v-reka-z-index` in the
reka renderer, or as a PrimeVue `p-dialog-mask` in the cloud build).
`SecretFormDialog` is a standalone reka dialog whose overlay/content
carried only the **static** `z-1700` class and never joined the
sequence, so it sat at a fixed `1700`. Whenever the shared counter has
climbed past 1700 (the report's `2102/2103` state, reached through
repeated dialog/section navigation) the Settings modal paints on top —
hence the intermittency. The ~3-line fix lets Add Secret join the same
sequence so it always opens above.

Scope is intentionally limited to `SecretFormDialog`. Two related items
are filed as follow-ups: FE-940 (durable self-registration in the shared
dialog primitives so static `z-1700` can be dropped everywhere) and
FE-941 (`VideoHelpDialog`, same bug class under the primevue-renderer
`UploadModelDialog`).

Linear:
[FE-939](https://linear.app/comfyorg/issue/FE-939/add-secret-dialog-renders-behind-settings-modal)

## Test plan

New `SecretFormDialog.zindex.test.ts` proves the fix red→green: with a
modal already registered at `1700`, the unfixed dialog content exposes
no inline z-index (`0`, fails the ordering assertion) and the fixed
dialog registers strictly above it (passes). It asserts a **relative**
ordering against a real prior registration, not a hardcoded constant, so
it is a behavioral regression guard rather than a change-detector.
(`@cloud` e2e deferred to FE-940/FE-941 follow-up work — the unit test
is the regression guard.)

CI red→green proof (Tests Unit):
- 🔴 test-only commit `f68d28b6` — [run
failed](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27001789061)
(`AssertionError: expected 0 to be greater than 1701`)
- 🟢 fix commit `add1862db1` —
[run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27002203164)

## Screenshots

Reproduced on cloud with the shared PrimeVue `'modal'` counter driven to
the reported climbed state (`z-2102`); the only difference between the
two is whether Add Secret joins the sequence.

| Before (bug) — Add Secret hidden behind Settings | After (fix) — Add
Secret on top |
| --- | --- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c0357dcd-5554-450e-8d4f-5a589a1566f6"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/80b7dd3f-5a73-4bfb-ae38-1eaed115bb8d"
/> |
2026-06-07 04:40:35 +00:00
Dante
f8187cec4c test: e2e for on-node grid thumbnail previews (FE-741) (#12667)
## Summary

Adds an end-to-end (Playwright) test for the on-node image-grid
thumbnail behavior introduced in #12561 (FE-741), kept in its own PR so
#12561 stays scoped to the fix + unit tests.

#12561 makes small on-node grid cells request a lightweight thumbnail
URL (`preview=webp;75`, server-resized via `res` on cloud) instead of
downloading the full-resolution `/api/view` image. That PR covers the
helper and component with unit tests. This PR adds the missing
**integration** coverage: it drives a real `Preview Image` node in the
browser, injects a multi-image grid over the websocket, and asserts the
rendered grid `<img>` elements request thumbnails.

It exercises the full path the unit tests can't:

`executed` WS output → `nodeOutputStore.buildImageUrls` →
`getGridThumbnailUrl` → rendered grid `<img>` `src`

## What it checks

- A 4-image `Preview Image` grid renders 4 cells (`viewMode ===
'grid'`).
- Every grid cell `<img>` `src` carries the compact thumbnail format
(`preview=webp;75`; the `;` may be percent-encoded as `%3B`).
- Each `src` still points at the real `/api/view` URL for that output
(`filename=grid-<n>.png`), confirming it's the thumbnailed view URL, not
a placeholder/blob.

Lives next to the existing batch-preview test in
`browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts`,
reusing the `ExecutionHelper` + `webSocketFixture` injection pattern.

## Notes

- **Stacked on #12561** (`jaewon/fe-741-onnode-grid-thumbnail-preview`).
Without that fix the grid cells use the full-res URL and this test is
red — i.e. it's a true regression guard. Retarget to `main` once #12561
merges.
- Gallery/full-view URLs staying at full resolution is already covered
by the unit tests in #12561 (`currentImageUrl` is left untransformed);
this test deliberately scopes to the grid path to avoid depending on
injected previews actually resolving on the backend.

## Test Plan

- [x] `pnpm typecheck:browser` clean
- [x] ESLint + oxlint clean
- [ ] e2e CI (`ci-tests-e2e`) green on this PR
2026-06-06 13:10:44 +00:00
Terry Jia
7e61358724 FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12627)
## Summary
The scene widget's serializeValue uploaded a fresh temp image on every
queue, so the `image / mask / normal` filenames in the prompt JSON were
new each run. The backend cache key (which hashes those input strings)
never matched, forcing Load3D and every downstream node to re-execute
even when the user changed nothing.

Track a session-scoped dirty flag and last-output cache in module-level
WeakMaps keyed by LGraphNode. serializeValue returns the cached output
when nothing has changed; user actions that mutate the visible scene
(scene/model/camera/light config, animation, recording, gizmo, camera
orbit) mark dirty through useLoad3d watchers and event handlers. The
model_file / width / height widget callbacks invalidate via a new
optional onSceneInvalidated hook plumbed through Load3DConfiguration, so
the captured screenshot stays consistent with the inputs the backend
sees.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/5ee5f79f-dd38-401e-babe-4d6ea156e56d

After

https://github.com/user-attachments/assets/5e00beb4-937c-4c66-abb2-e455f5301de6
2026-06-06 11:58:52 +00:00
Alexander Brown
ff9e6415b5 fix(nodes-2): apply Textarea widget font-size setting in Vue Nodes 2.0 (#12386)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Settings → Appearance → Node Widget → Textarea widget font size`
(`Comfy.TextareaWidget.FontSize`) was wired through the legacy LiteGraph
textarea only. The Vue Nodes 2.0 `WidgetTextarea.vue` hardcoded Tailwind
`text-xs`, so once Vue nodes were enabled the slider had no effect.

`GraphView.vue` already writes the setting value to
`--comfy-textarea-font-size` on `:root` for the legacy
`.comfy-multiline-input` rule. This PR makes `WidgetTextarea` consume
the same variable via Tailwind v4's parenthesized CSS-variable
shorthand, keeping `GraphView` as the single source of truth.

- `text-xs` → `text-(length:--comfy-textarea-font-size) leading-normal`.
The `length:` type hint is required because `text-` is ambiguous between
`font-size` and `color`. `leading-normal` keeps line-height proportional
to font-size across the 8–24 px range so multi-line text doesn't clip at
the high end.
- Initialize `--comfy-textarea-font-size: 10px` on `:root` in the
design-system stylesheet so isolated renders (Storybook, tests) that do
not mount `GraphView` still pick up the documented default.

- Fixes
[FE-799](https://linear.app/comfyorg/issue/FE-799/bug-textarea-widget-font-size-setting-not-working-in-nodes-20)

## Verification

- `pnpm typecheck`, `pnpm lint`, `pnpm exec stylelint`, `pnpm exec oxfmt
--check`, `pnpm knip`, and `WidgetTextarea.test.ts` (20 tests) all pass.
- Manual browser verification with Vue Nodes 2.0 enabled and a
`CLIPTextEncode` node:
  - setting `8` → computed `font-size: 8px`
  - setting `22` → computed `font-size: 22px`
- setting `24` → computed `font-size: 24px`, computed `line-height:
36px` (ratio 1.5, no clipping)
- Confirmed the legacy LiteGraph path still resolves to
`comfy-multiline-input` with `font-size: 22px` when Vue Nodes is
disabled (no regression).
- Confirmed the `:root` default resolves to `10px` when `GraphView`'s
inline override is absent (Storybook-like environments).

## Out of scope (follow-up)

`WidgetMarkdown.vue` (the Vue Nodes 2.0 markdown/tiptap widget) also
hardcodes `text-sm`. The legacy `.comfy-markdown .tiptap` rule reads the
same `--comfy-textarea-font-size` variable, so the setting historically
governed markdown widgets in Nodes 1.0. Bringing that into line with
this PR's approach is a follow-up the design team should weigh in on
before changing.

## Screenshots


![textarea-fontsize-8px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449630-3046bbe9-cb29-41f7-8994-9d251bd0ab5d.png)


![textarea-fontsize-22px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449987-46aed4a1-b09c-4b2e-88cf-e6302944c319.png)


![textarea-fontsize-24px-multiline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307450337-164136c9-b1e2-4dac-8390-4d935d416675.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12386-fix-nodes-2-apply-Textarea-widget-font-size-setting-in-Vue-Nodes-2-0-3666d73d365081fd8084e84a41ee357b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-06 00:26:51 +00:00
194 changed files with 11651 additions and 3880 deletions

View File

@@ -1,38 +1,18 @@
import { expect } from '@playwright/test'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_PREFIX
} from '../src/components/affiliates/affiliateFaqs'
import { programDetailRows } from '../src/components/affiliates/programDetails'
import type { TranslationKey } from '../src/i18n/translations'
import { affiliateFaqs } from '../src/data/affiliateFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const FIRST_FAQ_QUESTION = t(
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
'en'
)
const FIRST_FAQ_ANSWER = t(
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
'en'
)
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
const PATH = '/affiliates'
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
const SECTION_TESTIDS = [
'affiliate-hero',
'affiliate-trust-band',
'affiliate-how-it-works',
'affiliate-audience',
'affiliate-program-details',
'affiliate-brand-assets',
'affiliate-footer-cta'
] as const
const TERMS_PATH = '/affiliates/terms'
const FAQ_COUNT = affiliateFaqs.length
const FIRST_FAQ = affiliateFaqs[0]
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -41,32 +21,38 @@ test.describe('Affiliates landing — desktop @smoke', () => {
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('renders every page section in top-to-bottom order', async ({
page
}) => {
const ys: number[] = []
for (const id of SECTION_TESTIDS) {
const section = page.getByTestId(id)
await expect(section).toBeVisible()
const box = await section.boundingBox()
expect(box, `${id} bounding box`).not.toBeNull()
ys.push(box!.y)
}
const sortedYs = [...ys].sort((a, b) => a - b)
expect(ys).toEqual(sortedYs)
test('renders the closing CTA heading and apply button', async ({ page }) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const ctaHeading = ctaSection.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await expect(applyButton).toBeVisible()
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
await expect(applyButton).toHaveAttribute('target', '_blank')
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
})
test('renders the program details table on desktop', async ({ page }) => {
const table = page.getByTestId('affiliate-program-details-table')
await expect(table).toBeVisible()
const rows = table.getByRole('row')
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
test('CTA section links to the affiliate terms page in the same tab', async ({
page
}) => {
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
await termsLink.scrollIntoViewIfNeeded()
await expect(termsLink).toBeVisible()
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
await expect(termsLink).not.toHaveAttribute('target', '_blank')
})
})
@@ -93,42 +79,21 @@ test.describe('Affiliates landing — desktop interactions', () => {
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
})
test('hero and footer CTAs target the application form in a new tab', async ({
page
}) => {
const heroCta = page.getByTestId('affiliate-hero-cta')
await expect(heroCta).toBeVisible()
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
await expect(heroCta).toHaveAttribute('target', '_blank')
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
const footerCta = page.getByTestId('affiliate-footer-cta-button')
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
await expect(footerCta).toHaveAttribute('target', '_blank')
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
})
test('footer links to the affiliate terms page as a same-tab navigation', async ({
page
}) => {
const link = page
.getByTestId('affiliate-footer-cta')
.getByRole('link', { name: /Read the affiliate program terms/i })
await expect(link).toBeVisible()
await expect(link).toBeEnabled()
await expect(link).toHaveAttribute('href', '/affiliates/terms')
await expect(link).not.toHaveAttribute('target', '_blank')
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({
page,
context
}) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await applyButton.scrollIntoViewIfNeeded()
const popupPromise = context.waitForEvent('page')
await page.getByTestId('affiliate-hero-cta').click()
await applyButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
@@ -140,12 +105,15 @@ test.describe('Affiliates landing — desktop interactions', () => {
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
@@ -157,44 +125,24 @@ test.describe('Affiliates landing — mobile @mobile', () => {
await page.goto(PATH)
})
test('renders the hero heading and primary CTA at narrow viewports', async ({
page
}) => {
test('renders the hero heading at narrow viewports', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
).toBeVisible()
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
})
test('program details collapse to a stacked definition list', async ({
page
}) => {
await expect(
page.getByTestId('affiliate-program-details-table')
).toBeHidden()
const detailsList = page
.getByTestId('affiliate-program-details')
.locator('dl')
await expect(detailsList).toBeVisible()
await expect(
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
).toBeVisible()
await expect(
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
})
test('all major sections remain visible without horizontal overflow', async ({
page
}) => {
for (const id of SECTION_TESTIDS) {
const section = page.getByTestId(id)
await expect(section).toBeVisible()
const box = await section.boundingBox()
expect(box, `${id} bounding box`).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
}
test('closing CTA stays within the viewport width', async ({ page }) => {
const ctaHeading = page.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const box = await ctaHeading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -38,7 +42,7 @@ test.describe('Download page @smoke', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
@@ -176,7 +235,7 @@ test.describe('Download page mobile @mobile', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()

View File

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

View File

@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Local',
'Comfy Desktop',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

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

Before

Width:  |  Height:  |  Size: 234 B

View File

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

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const audienceKeys: TranslationKey[] = [
'affiliate-landing.audience.item.0',
'affiliate-landing.audience.item.1',
'affiliate-landing.audience.item.2',
'affiliate-landing.audience.item.3',
'affiliate-landing.audience.item.4'
]
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-audience"
>
<SectionHeader>
{{ t('affiliate-landing.audience.heading', locale) }}
</SectionHeader>
<ul class="mx-auto mt-12 flex max-w-3xl flex-col gap-4">
<li
v-for="key in audienceKeys"
:key="key"
class="text-primary-comfy-canvas flex items-start gap-4 text-base md:text-lg"
>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
aria-hidden="true"
>
</span>
<span>{{ t(key, locale) }}</span>
</li>
</ul>
</section>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
import { brandAssets } from './brandAssets'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-brand-assets"
>
<SectionHeader>
{{ t('affiliate-landing.assets.heading', locale) }}
<template #subtitle>
<p
class="text-primary-comfy-canvas/70 mx-auto mt-4 max-w-2xl text-base"
>
{{ t('affiliate-landing.assets.subheading', locale) }}
</p>
</template>
</SectionHeader>
<ul
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
>
<li
v-for="asset in brandAssets"
:key="asset.id"
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col overflow-hidden rounded-4xl border"
:data-testid="`affiliate-asset-${asset.id}`"
>
<div
class="bg-primary-comfy-ink/40 flex aspect-video items-center justify-center overflow-hidden p-6"
>
<img
:src="asset.preview"
:alt="t(asset.titleKey, locale)"
class="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div class="flex flex-1 flex-col gap-2 p-5">
<h3 class="text-primary-comfy-canvas text-base font-light">
{{ t(asset.titleKey, locale) }}
</h3>
<a
:href="asset.download"
:download="asset.download.split('/').pop()"
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
>
{{ t('affiliate-landing.assets.downloadLabel', locale) }}
<span aria-hidden="true"></span>
</a>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<section
class="bg-secondary-mauve/30 border-primary-comfy-canvas/10 mt-12 border-t px-6 py-20 text-center md:py-28"
data-testid="affiliate-footer-cta"
>
<h2
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl lg:text-5xl"
>
{{ t('affiliate-landing.footerCta.heading', locale) }}
</h2>
<div class="mt-8 flex flex-col items-center gap-4">
<BrandButton
:href="externalLinks.affiliateApplicationForm"
target="_blank"
rel="noopener noreferrer"
size="lg"
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
data-testid="affiliate-footer-cta-button"
class="px-8 py-4 text-base"
>
{{ t('affiliate-landing.cta.apply', locale) }}
</BrandButton>
<a
:href="routes.affiliateTerms"
class="text-primary-comfy-canvas/70 text-sm underline-offset-4 hover:underline"
>
{{ t('affiliate-landing.footerCta.termsLink', locale) }}
</a>
</div>
</section>
</template>

View File

@@ -1,84 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const highlightKeys: TranslationKey[] = [
'affiliate-landing.hero.highlight.0',
'affiliate-landing.hero.highlight.1',
'affiliate-landing.hero.highlight.2',
'affiliate-landing.hero.highlight.3'
]
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-stretch gap-10 px-6 pt-12 pb-16 lg:flex-row lg:items-center lg:gap-16 lg:px-20 lg:pt-20 lg:pb-24"
data-testid="affiliate-hero"
>
<div class="flex-1">
<h1
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
>
{{ t('affiliate-landing.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-yellow mt-4 text-2xl font-light md:text-3xl lg:text-4xl"
>
{{ t('affiliate-landing.hero.subheading', locale) }}
</p>
<p class="text-primary-comfy-canvas/80 mt-6 max-w-xl text-base">
{{ t('affiliate-landing.hero.body', locale) }}
</p>
<ul class="mt-6 flex flex-col gap-3">
<li
v-for="key in highlightKeys"
:key="key"
class="text-primary-comfy-canvas flex items-start gap-3 text-base"
>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
aria-hidden="true"
>
</span>
<span>{{ t(key, locale) }}</span>
</li>
</ul>
<div class="mt-8">
<BrandButton
:href="externalLinks.affiliateApplicationForm"
target="_blank"
rel="noopener noreferrer"
size="lg"
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
data-testid="affiliate-hero-cta"
class="px-8 py-4 text-base"
>
{{ t('affiliate-landing.cta.apply', locale) }}
</BrandButton>
</div>
</div>
<div
class="flex flex-1 items-center justify-center"
data-testid="affiliate-hero-media"
>
<slot name="media">
<video
src="https://media.comfy.org/website/homepage/showcase/ui-overview.webm"
autoplay
loop
muted
playsinline
aria-hidden="true"
class="w-full max-w-xl rounded-4xl"
/>
</slot>
</div>
</section>
</template>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps: { titleKey: TranslationKey; bodyKey: TranslationKey }[] = [
{
titleKey: 'affiliate-landing.how.step.0.title',
bodyKey: 'affiliate-landing.how.step.0.body'
},
{
titleKey: 'affiliate-landing.how.step.1.title',
bodyKey: 'affiliate-landing.how.step.1.body'
},
{
titleKey: 'affiliate-landing.how.step.2.title',
bodyKey: 'affiliate-landing.how.step.2.body'
}
]
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-how-it-works"
>
<SectionHeader>
{{ t('affiliate-landing.how.heading', locale) }}
</SectionHeader>
<ol
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
>
<li
v-for="(step, index) in steps"
:key="step.titleKey"
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col gap-4 rounded-4xl border p-8"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ index + 1 }}
</span>
<h3 class="text-primary-comfy-canvas text-2xl font-light">
{{ t(step.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas/70 text-sm">
{{ t(step.bodyKey, locale) }}
</p>
</li>
</ol>
</section>
</template>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
import { programDetailRows } from './programDetails'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-program-details"
>
<SectionHeader>
{{ t('affiliate-landing.details.heading', locale) }}
</SectionHeader>
<div class="mx-auto mt-12 max-w-3xl">
<div class="hidden md:block">
<table
class="w-full border-collapse text-left"
data-testid="affiliate-program-details-table"
>
<thead>
<tr class="border-primary-comfy-canvas/20 border-b">
<th
scope="col"
class="text-primary-comfy-yellow py-4 pr-6 text-xs font-bold tracking-widest uppercase"
>
{{ t('affiliate-landing.details.headerLabel', locale) }}
</th>
<th
scope="col"
class="text-primary-comfy-yellow py-4 text-xs font-bold tracking-widest uppercase"
>
{{ t('affiliate-landing.details.headerValue', locale) }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in programDetailRows"
:key="row.labelKey"
class="border-primary-comfy-canvas/10 border-b"
>
<th
scope="row"
class="text-primary-comfy-canvas py-5 pr-6 text-base font-light"
>
{{ t(row.labelKey, locale) }}
</th>
<td class="text-primary-comfy-canvas/80 py-5 text-base">
{{ t(row.valueKey, locale) }}
</td>
</tr>
</tbody>
</table>
</div>
<dl class="flex flex-col gap-6 md:hidden">
<div
v-for="row in programDetailRows"
:key="row.labelKey"
class="border-primary-comfy-canvas/10 flex flex-col gap-1 border-b pb-4"
>
<dt
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t(row.labelKey, locale) }}
</dt>
<dd class="text-primary-comfy-canvas text-base">
{{ t(row.valueKey, locale) }}
</dd>
</div>
</dl>
</div>
</section>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="border-primary-comfy-canvas/10 border-y px-6 py-10 text-center md:py-12"
data-testid="affiliate-trust-band"
>
<p
class="text-primary-comfy-canvas text-sm tracking-wider uppercase md:text-base"
>
{{ t('affiliate-landing.trust.label', locale) }}
</p>
</section>
</template>

View File

@@ -1,6 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
export const AFFILIATE_FAQ_PREFIX = 'affiliate-landing.faq'
export const AFFILIATE_FAQ_HEADING_KEY: TranslationKey =
'affiliate-landing.faq.heading'
export const AFFILIATE_FAQ_COUNT = 8

View File

@@ -1,154 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_HEADING_KEY,
AFFILIATE_FAQ_PREFIX
} from './affiliateFaqs'
import { brandAssets } from './brandAssets'
const PREFIX = 'affiliate-landing'
const EXPECTED_SECTION_PREFIXES = [
'page',
'cta',
'hero',
'trust',
'how',
'audience',
'details',
'assets',
'faq',
'footerCta'
] as const
const HERO_HIGHLIGHT_COUNT = 4
const HOW_STEP_COUNT = 3
const AUDIENCE_ITEM_COUNT = 5
const DETAILS_ROW_COUNT = 6
const INTERNAL_KEY_PATTERNS = [
/open-questions/,
/todo/i,
/draft/i,
/placeholder/i,
/internal/i
]
function affiliateKeys(): string[] {
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
}
describe('affiliate landing i18n', () => {
it('exposes the canonical top-level section prefixes', () => {
const keys = affiliateKeys()
for (const section of EXPECTED_SECTION_PREFIXES) {
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
expect(hit, `missing section: ${section}`).toBe(true)
}
})
it('orders sections as the page renders them', () => {
const keys = affiliateKeys()
const seenSections: string[] = []
for (const key of keys) {
const section = key.split('.')[1]
if (!section) continue
if (!seenSections.includes(section)) seenSections.push(section)
}
const orderedExpected = EXPECTED_SECTION_PREFIXES.filter((s) =>
seenSections.includes(s)
)
const orderedActual = seenSections.filter((s) =>
(EXPECTED_SECTION_PREFIXES as readonly string[]).includes(s)
)
expect(orderedActual).toEqual([...orderedExpected])
})
it('exposes hero, page, and cta keys editors will need', () => {
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
expect(hasKey(`${PREFIX}.cta.apply`)).toBe(true)
expect(hasKey(`${PREFIX}.cta.applyAriaLabel`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.subheading`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.body`)).toBe(true)
for (let i = 0; i < HERO_HIGHLIGHT_COUNT; i++) {
expect(hasKey(`${PREFIX}.hero.highlight.${i}`)).toBe(true)
}
})
it('exposes the trust band, how-it-works, and audience copy', () => {
expect(hasKey(`${PREFIX}.trust.label`)).toBe(true)
expect(hasKey(`${PREFIX}.how.heading`)).toBe(true)
for (let i = 0; i < HOW_STEP_COUNT; i++) {
expect(hasKey(`${PREFIX}.how.step.${i}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.how.step.${i}.body`)).toBe(true)
}
expect(hasKey(`${PREFIX}.audience.heading`)).toBe(true)
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
expect(hasKey(`${PREFIX}.audience.item.${i}`)).toBe(true)
}
})
it('exposes the program details rows', () => {
expect(hasKey(`${PREFIX}.details.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.details.headerLabel`)).toBe(true)
expect(hasKey(`${PREFIX}.details.headerValue`)).toBe(true)
for (let i = 0; i < DETAILS_ROW_COUNT; i++) {
expect(hasKey(`${PREFIX}.details.row.${i}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.details.row.${i}.value`)).toBe(true)
}
})
it('matches every brand-asset tile to a translation key', () => {
expect(hasKey(`${PREFIX}.assets.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.assets.subheading`)).toBe(true)
expect(hasKey(`${PREFIX}.assets.downloadLabel`)).toBe(true)
for (const asset of brandAssets) {
expect(hasKey(asset.titleKey)).toBe(true)
}
})
it('exposes every 1-indexed faq.<n>.q/a pair from 1 to AFFILIATE_FAQ_COUNT (FAQSection contract)', () => {
expect(AFFILIATE_FAQ_PREFIX).toBe(`${PREFIX}.faq`)
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
for (let n = 1; n <= AFFILIATE_FAQ_COUNT; n++) {
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.q`)).toBe(true)
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.a`)).toBe(true)
}
})
it('keeps AFFILIATE_FAQ_COUNT in sync with the actual faq.<n>.q keys in translations', () => {
const faqQuestionKeyPattern = new RegExp(
`^${AFFILIATE_FAQ_PREFIX}\\.(\\d+)\\.q$`
)
const indices = translationKeys
.map((k) => k.match(faqQuestionKeyPattern)?.[1])
.filter((m): m is string => m !== undefined)
.map((s) => parseInt(s, 10))
.sort((a, b) => a - b)
expect(indices).toEqual(
Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => i + 1)
)
})
it('exposes the footer cta copy', () => {
expect(hasKey(`${PREFIX}.footerCta.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.footerCta.termsLink`)).toBe(true)
})
it('returns non-empty english copy for every affiliate-landing key', () => {
for (const key of affiliateKeys()) {
expect(t(key as never, 'en').trim().length).toBeGreaterThan(0)
}
})
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
const leaks = affiliateKeys().filter((k) =>
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
)
expect(leaks).toEqual([])
})
})

View File

@@ -1,59 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
interface BrandAsset {
id: string
titleKey: TranslationKey
download: string
preview: string
}
export const brandAssets: BrandAsset[] = [
{
id: 'logo-horizontal',
titleKey: 'affiliate-landing.assets.tile.logo-horizontal.title',
download: '/icons/logo.svg',
preview: '/icons/logo.svg'
},
{
id: 'logomark',
titleKey: 'affiliate-landing.assets.tile.logomark.title',
download: '/icons/logomark.svg',
preview: '/icons/logomark.svg'
},
{
id: 'comfy-full-logo-yellow',
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-yellow.title',
download: '/affiliates/brand/comfy-full-logo-yellow.svg',
preview: '/affiliates/brand/comfy-full-logo-yellow.svg'
},
{
id: 'comfy-full-logo-ink',
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-ink.title',
download: '/affiliates/brand/comfy-full-logo-ink.svg',
preview: '/affiliates/brand/comfy-full-logo-ink.svg'
},
{
id: 'amplified-logo-mark',
titleKey: 'affiliate-landing.assets.tile.amplified-logo-mark.title',
download: '/affiliates/brand/comfy-amplified-logo-mark.svg',
preview: '/affiliates/brand/comfy-amplified-logo-mark.svg'
},
{
id: 'dimensional-logo-mark',
titleKey: 'affiliate-landing.assets.tile.dimensional-logo-mark.title',
download: '/affiliates/brand/comfy-dimensional-logo-mark.svg',
preview: '/affiliates/brand/comfy-dimensional-logo-mark.svg'
},
{
id: 'color-combo-yellow',
titleKey: 'affiliate-landing.assets.tile.color-combo-yellow.title',
download: '/affiliates/brand/comfy-color-combo-yellow.svg',
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
},
{
id: 'color-combo-ink',
titleKey: 'affiliate-landing.assets.tile.color-combo-ink.title',
download: '/affiliates/brand/comfy-color-combo-ink.svg',
preview: '/affiliates/brand/comfy-color-combo-ink.svg'
}
]

View File

@@ -1,33 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
interface ProgramDetailRow {
labelKey: TranslationKey
valueKey: TranslationKey
}
export const programDetailRows: ProgramDetailRow[] = [
{
labelKey: 'affiliate-landing.details.row.0.label',
valueKey: 'affiliate-landing.details.row.0.value'
},
{
labelKey: 'affiliate-landing.details.row.1.label',
valueKey: 'affiliate-landing.details.row.1.value'
},
{
labelKey: 'affiliate-landing.details.row.2.label',
valueKey: 'affiliate-landing.details.row.2.value'
},
{
labelKey: 'affiliate-landing.details.row.3.label',
valueKey: 'affiliate-landing.details.row.3.value'
},
{
labelKey: 'affiliate-landing.details.row.4.label',
valueKey: 'affiliate-landing.details.row.4.value'
},
{
labelKey: 'affiliate-landing.details.row.5.label',
valueKey: 'affiliate-landing.details.row.5.value'
}
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
const { faqs } = defineProps<{
heading: string
faqs: readonly Faq[]
}>()
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
() => faqs.length,
(length) => {
if (length === expanded.length) return
expanded.length = 0
for (let i = 0; i < length; i += 1) expanded.push(false)
}
)
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ heading }}
</h2>
</div>
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${faq.id}`"
type="button"
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${faq.id}`"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between text-left',
index === 0 ? 'pb-6' : 'py-6'
)
"
@click="toggle(index)"
>
<span
:class="
cn(
'text-lg font-light md:text-xl',
expanded[index]
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas'
)
"
>
{{ faq.question }}
</span>
<span
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
aria-hidden="true"
>
{{ expanded[index] ? '' : '+' }}
</span>
</button>
<section
v-show="expanded[index]"
:id="`faq-panel-${faq.id}`"
role="region"
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,7 +94,7 @@ const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'LOCAL',
core: 'DESKTOP',
href: routes.download,
primary: false
},
@@ -164,7 +164,7 @@ onMounted(() => {
/>
<nav
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
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

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

View File

@@ -7,12 +7,16 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<!-- Desktop hover overlay -->
<div

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ const demoVideoPoster =
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
@@ -31,7 +31,7 @@ const demoVideoPoster =
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
@@ -15,21 +16,6 @@ const { tutorial, locale = 'en' } = defineProps<{
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
@@ -42,7 +28,6 @@ function handleKeydown(e: KeyboardEvent) {
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
@@ -62,31 +47,30 @@ onUnmounted(() => {
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<video
ref="videoRef"
<VideoPlayer
:key="tutorial.id"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
class="w-full"
/>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}

View File

@@ -22,7 +22,7 @@ const activeTutorial = () =>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
@@ -71,9 +71,9 @@ const activeTutorial = () =>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CheckIcon from '../icons/CheckIcon.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -94,12 +95,9 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<img
<CheckIcon
v-else
src="/icons/check.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}

View File

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

View File

@@ -283,16 +283,16 @@ onUnmounted(() => {
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
>
<ProductHeroBadge />
<ProductHeroBadge text="DESKTOP" />
<h1
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
</p>
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-24 w-full">
<div class="mt-16 w-full lg:mt-24">
<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
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
>
<span class="lg:hidden">{{
t('cloud.aiModels.ctaMobile', locale)
}}</span>
<span class="hidden lg:inline">{{
<!-- <span class="lg:hidden"> -->
{{ t('cloud.aiModels.ctaMobile', locale) }}
<!-- </span> -->
<!-- <span class="hidden lg:inline">{{
t('cloud.aiModels.ctaDesktop', locale)
}}</span>
}}</span> -->
</BrandButton>
</div>
</section>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
const downloadUrls = {
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)
const downloadUrl = computed(() => {
if (platform.value === 'windows') return downloadUrls.windows
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
return externalLinks.github
})
const showFallback = computed(
() => detected.value && !platform.value && !isMobileUa.value
)
onMounted(() => {
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
const ua = navigator.userAgent.toLowerCase()
isMobileUa.value = isMobile(ua)
platform.value = detectPlatform(ua)
detected.value = true
})
return { downloadUrl, platform }
return { downloadUrl, platform, showFallback }
}

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
'grok-imagine': {
hubSlug: 'grok',
featured: false
},

View File

@@ -0,0 +1,44 @@
import type { LocalizedText } from '../i18n/translations'
interface AudienceCriterion {
id: string
label: LocalizedText
}
export const affiliateAudienceCriteria: readonly AudienceCriterion[] = [
{
id: 'tutorial-creator',
label: {
en: 'A ComfyUI tutorial creator or workflow builder',
'zh-CN': 'ComfyUI 教程作者或工作流创建者'
}
},
{
id: 'ai-tool-reviewer',
label: {
en: 'An AI tool reviewer on YouTube, TikTok, blogs',
'zh-CN': '在 YouTube、TikTok、博客上做 AI 工具测评'
}
},
{
id: 'tech-blogger',
label: {
en: 'A tech blogger covering AI creative tools',
'zh-CN': '报道 AI 创作工具的科技博主'
}
},
{
id: 'newsletter-operator',
label: {
en: 'A newsletter operator in the AI/creative space',
'zh-CN': 'AI创意领域的简报运营者'
}
},
{
id: 'audience-owner',
label: {
en: 'Anyone with an audience interested in AI image, video, or 3D',
'zh-CN': '拥有关注 AI 图像、视频或 3D 受众的任何人'
}
}
] as const

View File

@@ -0,0 +1,39 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateBenefit {
id: string
description: LocalizedText
}
export const affiliateBenefits: readonly AffiliateBenefit[] = [
{
id: 'open-source-platform',
description: {
en: 'ComfyUI is the most powerful open-source AI creative platform',
'zh-CN': 'ComfyUI 是最强大的开源 AI 创作平台'
}
},
{
id: 'cloud-no-gpu',
description: {
en: 'Comfy Cloud lets you run ComfyUI in the browser, no GPU needed, all models pre-loaded',
'zh-CN':
'Comfy Cloud 让你在浏览器中运行 ComfyUI无需 GPU所有模型预加载'
}
},
{
id: 'node-based-control',
description: {
en: 'Node-based workflows give users full creative control unlike prompt-only tools',
'zh-CN':
'基于节点的工作流让用户拥有完整的创作控制力,区别于仅靠提示词的工具'
}
},
{
id: 'custom-nodes',
description: {
en: '1,000+ community custom node packages',
'zh-CN': '1,000+ 社区自定义节点包'
}
}
] as const

View File

@@ -0,0 +1,38 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
download: string
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',
title: { en: 'Core Logo', 'zh-CN': '核心标志' },
download: BRAND_ASSETS_ZIP,
preview: '/icons/logo.svg'
},
{
id: 'logomark',
title: { en: 'Logomark', 'zh-CN': '标志符号' },
download: BRAND_ASSETS_ZIP,
preview: '/icons/logomark.svg'
},
{
id: 'icon',
title: { en: 'Icon', 'zh-CN': '图标' },
download: BRAND_ASSETS_ZIP,
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
},
{
id: 'amplified-logomark',
title: { en: 'Amplified Logomark', 'zh-CN': '放大版标志符号' },
download: BRAND_ASSETS_ZIP,
preview: '/affiliates/brand/comfy-amplified-logo.png'
}
] as const

View File

@@ -0,0 +1,103 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateFaq {
id: string
question: LocalizedText
answer: LocalizedText
}
export const affiliateFaqs: readonly AffiliateFaq[] = [
{
id: 'how-do-i-track-my-referrals',
question: {
en: 'How do I track my referrals?',
'zh-CN': '我如何追踪我的推荐?'
},
answer: {
en: 'Real-time dashboard via our partner portal.',
'zh-CN': '通过我们的合作伙伴门户使用实时仪表盘追踪。'
}
},
{
id: 'what-plans-qualify',
question: {
en: 'What plans qualify?',
'zh-CN': '哪些订阅方案符合条件?'
},
answer: {
en: 'All Comfy Cloud paid subscription plans (Standard, Creator, Pro, Teams).',
'zh-CN':
'所有 Comfy Cloud 付费订阅方案Standard、Creator、Pro、Teams。'
}
},
{
id: 'how-long-to-get-approved',
question: {
en: 'How long does approval take?',
'zh-CN': '审核需要多长时间?'
},
answer: {
en: 'Most applications approved within 24 hours.',
'zh-CN': '大多数申请会在 24 小时内获批。'
}
},
{
id: 'when-do-i-get-paid',
question: {
en: 'When do I get paid?',
'zh-CN': '什么时候结算佣金?'
},
answer: {
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
'zh-CN':
'每月结算,于每月前 10 个工作日内发放。最低结算余额为 100 美元,通过 Stripe Express 或 PayPal 支付。'
}
},
{
id: 'what-happens-if-referral-upgrades-or-downgrades',
question: {
en: 'What happens if my referral upgrades or downgrades?',
'zh-CN': '如果我推荐的用户升级或降级订阅会怎样?'
},
answer: {
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
'zh-CN':
'如果他们升级订阅,您的佣金会相应增加;如果降级,佣金也会同步调整。佣金以 Comfy.org 实际收到的金额为准,并扣除退款部分。'
}
},
{
id: 'can-i-use-coupon-codes',
question: {
en: 'Can I use coupon codes?',
'zh-CN': '我可以使用优惠码吗?'
},
answer: {
en: 'Yes. We support both tracking links and unique coupon codes.',
'zh-CN': '可以。我们同时支持追踪链接和专属优惠码。'
}
},
{
id: 'what-if-my-referral-uses-an-ad-blocker',
question: {
en: 'What if my referral uses an ad blocker?',
'zh-CN': '如果我推荐的用户使用广告拦截器怎么办?'
},
answer: {
en: 'We use server-side tracking, so conversions are tracked regardless.',
'zh-CN':
'我们采用服务端追踪,因此无论用户是否使用广告拦截器,转化都能正常记录。'
}
},
{
id: 'what-assets-do-you-provide',
question: {
en: 'What assets do you provide?',
'zh-CN': '你们提供哪些素材?'
},
answer: {
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
'zh-CN':
'本页面提供 Logo 和横幅图,获批后您还可以在联盟仪表盘中获取截图和宣传文案。'
}
}
] as const

View File

@@ -0,0 +1,45 @@
import type { LocalizedText } from '../i18n/translations'
interface HowItWorksStep {
id: string
label: LocalizedText
description: LocalizedText
}
export const affiliateHowItWorksSteps: readonly HowItWorksStep[] = [
{
id: 'apply',
label: {
en: 'Apply',
'zh-CN': '申请'
},
description: {
en: 'Submit a quick form. Most applicants approved same day.',
'zh-CN': '填写一份简短表单。大多数申请当天获批。'
}
},
{
id: 'share',
label: {
en: 'Share',
'zh-CN': '分享'
},
description: {
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
'zh-CN':
'获取您的专属追踪链接。通过内容、社交、邮件等任何触达受众的方式分享。'
}
},
{
id: 'earn',
label: {
en: 'Earn',
'zh-CN': '赚取'
},
description: {
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
'zh-CN':
'每位您推荐的 Comfy Cloud 订阅者,可获连续 3 个月 30% 的经常性佣金。实时追踪,每月结算。'
}
}
] as const

View File

@@ -7,6 +7,8 @@ export interface GalleryItem {
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}

View File

@@ -124,8 +124,8 @@ const translations = {
'zh-CN': '下载或注册'
},
'getStarted.step1.downloadLocal': {
en: 'Download Local',
'zh-CN': '下载本地版'
en: 'Download Desktop',
'zh-CN': '下载桌面版'
},
'getStarted.step1.launchCloud': {
en: 'Launch Cloud',
@@ -605,8 +605,8 @@ const translations = {
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
},
'download.faq.4.q': {
en: 'Why would I pay for Comfy Cloud if Local is free?',
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud'
en: 'Why would I pay for Comfy Cloud if Desktop is free?',
'zh-CN': '既然桌面版免费,为什么还要付费使用 Comfy Cloud'
},
'download.faq.4.a': {
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
@@ -623,8 +623,8 @@ const translations = {
'Desktop一键安装自动更新。Portable独立构建可从任意文件夹运行。CLI从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
},
'download.faq.6.q': {
en: 'Can I use my local workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
en: 'Can I use my Desktop workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用桌面工作流吗?'
},
'download.faq.6.a': {
en: 'Yes — same file, same results. No conversion, no rework.',
@@ -665,8 +665,8 @@ const translations = {
'zh-CN': '专业人士为何\n选择'
},
'download.reason.headingHighlight': {
en: 'Local',
'zh-CN': '本地版'
en: 'Desktop',
'zh-CN': '桌面版'
},
'download.reason.1.title': {
en: 'Unlimited\nCustomization',
@@ -715,8 +715,8 @@ const translations = {
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
},
'download.hero.downloadLocal': {
en: 'DOWNLOAD LOCAL',
'zh-CN': '下载本地版'
en: 'DOWNLOAD DESKTOP',
'zh-CN': '下载桌面版'
},
'download.hero.installGithub': {
en: 'INSTALL FROM GITHUB',
@@ -1810,7 +1810,7 @@ const translations = {
'nav.community': { en: 'Community', 'zh-CN': '社区' },
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
'nav.company': { en: 'Company', 'zh-CN': '公司' },
'nav.comfyLocal': { en: 'Comfy Local', 'zh-CN': 'Comfy 本地版' },
'nav.comfyLocal': { en: 'Comfy Desktop', 'zh-CN': 'Comfy 桌面版' },
'nav.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'nav.comfyApi': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
'nav.comfyEnterprise': {
@@ -1828,7 +1828,7 @@ const translations = {
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.downloadLocal': { en: 'DOWNLOAD LOCAL', 'zh-CN': '下载本地版' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
@@ -1879,8 +1879,9 @@ const translations = {
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
},
'privacy.intro.block.3': {
en: 'This policy is effective as of April 18, 2025.',
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
'zh-CN':
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop本地安装应用程序的具体信息包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
},
'privacy.information-we-collect.label': {
en: 'INFORMATION',
@@ -2130,6 +2131,181 @@ const translations = {
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
},
// ── Desktop Privacy Policy ────────────────────────────────────────
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
'desktop_privacy.intro.block.0': {
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
},
'desktop_privacy.intro.block.1': {
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
'zh-CN':
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
},
'desktop_privacy.controller.label': {
en: 'CONTROLLER',
'zh-CN': 'CONTROLLER'
},
'desktop_privacy.controller.title': {
en: 'Controller',
'zh-CN': 'Controller'
},
'desktop_privacy.controller.block.0': {
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.data.label': {
en: 'DATA WE PROCESS',
'zh-CN': 'DATA WE PROCESS'
},
'desktop_privacy.data.title': {
en: 'Personal data we process',
'zh-CN': 'Personal data we process'
},
'desktop_privacy.data.block.0': {
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
'zh-CN':
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
},
'desktop_privacy.data.block.1': {
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
'zh-CN':
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
},
'desktop_privacy.data.block.2': {
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
'zh-CN':
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
},
'desktop_privacy.data.block.3': {
en: 'We do not process:',
'zh-CN': 'We do not process:'
},
'desktop_privacy.data.block.4': {
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
'zh-CN':
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
},
'desktop_privacy.data.block.5': {
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
'zh-CN':
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
},
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
'desktop_privacy.purposes.title': {
en: 'Purposes and lawful bases',
'zh-CN': 'Purposes and lawful bases'
},
'desktop_privacy.purposes.block.0': {
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
'zh-CN':
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
},
'desktop_privacy.purposes.block.1': {
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
'zh-CN':
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
},
'desktop_privacy.purposes.block.2': {
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.purposes.block.3': {
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
'zh-CN':
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
},
'desktop_privacy.processors.label': {
en: 'RECIPIENTS',
'zh-CN': 'RECIPIENTS'
},
'desktop_privacy.processors.title': {
en: 'Recipients',
'zh-CN': 'Recipients'
},
'desktop_privacy.processors.block.0': {
en: 'We engage the following processors under Data Processing Agreements:',
'zh-CN':
'We engage the following processors under Data Processing Agreements:'
},
'desktop_privacy.processors.block.1': {
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
'zh-CN':
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
},
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
'desktop_privacy.transfers.title': {
en: 'International transfers',
'zh-CN': 'International transfers'
},
'desktop_privacy.transfers.block.0': {
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
'zh-CN':
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
},
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
'desktop_privacy.retention.block.0': {
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
'zh-CN':
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
},
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
'desktop_privacy.rights.block.0': {
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
'zh-CN':
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
},
'desktop_privacy.rights.block.1': {
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
'zh-CN':
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
},
'desktop_privacy.rights.block.2': {
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
'zh-CN':
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
},
'desktop_privacy.rights.block.3': {
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
'zh-CN':
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
},
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
'desktop_privacy.children.block.0': {
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
'zh-CN':
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
},
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
'desktop_privacy.changes.block.0': {
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
'zh-CN':
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
},
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
'desktop_privacy.contact.block.0': {
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.effectiveDateLabel': {
en: 'Effective Date',
@@ -4458,6 +4634,80 @@ const translations = {
'zh-CN': '支持的模型'
},
// Models list page (/models)
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
'models.list.heroCta': {
en: 'Try {name} Now',
'zh-CN': '立即试用 {name}'
},
'models.list.creations.title': {
en: '{name} Image and Video Creations',
'zh-CN': '{name} 图像与视频创作'
},
'models.list.creations.cta': {
en: 'Explore Workflows',
'zh-CN': '探索工作流'
},
'models.list.heroTitle.before': {
en: '{name} in',
'zh-CN': ''
},
'models.list.heroTitle.after': {
en: '',
'zh-CN': ' 中的 {name}'
},
'models.list.heroSubtitle': {
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
'zh-CN':
'从开源扩散模型到合作伙伴 API涵盖每一个主流模型并附带可直接运行的社区工作流模板。'
},
'models.list.card.workflows': {
en: '{count} workflows',
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
en: 'Run the worlds\nleading AI models',
'zh-CN': '运行全球领先的\nAI 模型'
},
'models.showcase.subtitle': {
en: 'New models are added as they launch.',
'zh-CN': '新模型发布后会第一时间上线。'
},
'models.showcase.cta': {
en: 'EXPLORE WORKFLOWS',
'zh-CN': '探索工作流'
},
'models.showcase.card.grokImagine': {
en: 'Grok Imagine',
'zh-CN': 'Grok Imagine'
},
'models.showcase.card.nanoBananaPro': {
en: 'Nano Banana Pro',
'zh-CN': 'Nano Banana Pro'
},
'models.showcase.card.ltx23': {
en: 'LTX 2.3',
'zh-CN': 'LTX 2.3'
},
'models.showcase.card.qwenAdvancedEdit': {
en: 'Advanced image\nediting with Qwen',
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
},
'models.showcase.card.wan22TextToVideo': {
en: 'Wan 2.2\ntext to video',
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',
@@ -4501,313 +4751,103 @@ const translations = {
'zh-CN': '查看订阅文档'
},
// Affiliate landing page (/affiliates)
// English-only copy; zh-CN values mirror en until a localized version lands.
'affiliate-landing.page.title': {
en: 'Comfy.org Affiliate Program — Become a Partner',
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
// AffiliateHeroSection
'affiliate.hero.label': { en: 'AFFILIATE', 'zh-CN': '联盟' },
'affiliate.hero.headingHighlight': {
en: 'Earn 30%',
'zh-CN': '赚取 30%'
},
'affiliate-landing.page.description': {
'affiliate.hero.headingMuted': {
en: 'recurring commission for 3 months.',
'zh-CN': '持续返佣 3 个月。'
},
'affiliate.hero.feature1': {
en: '30% recurring commission for 3 months',
'zh-CN': '30% 持续佣金,连续 3 个月'
},
'affiliate.hero.feature2': {
en: '60-day cookie window',
'zh-CN': '60 天 Cookie 窗口'
},
'affiliate.hero.feature3': {
en: '$100 minimum payout',
'zh-CN': '$100 起付'
},
'affiliate.hero.feature4': {
en: 'Monthly payouts',
'zh-CN': '每月结算'
},
'affiliate.hero.apply': { en: 'APPLY NOW', 'zh-CN': '立即申请' },
'affiliate.hero.imageAlt': {
en: 'Comfy affiliate program',
'zh-CN': 'Comfy 联盟计划'
},
// AffiliateAudienceSection
'affiliate.audience.heading': {
en: "Who we're looking for",
'zh-CN': '我们在寻找谁'
},
'affiliate.audience.subheading': {
en: 'If you are...',
'zh-CN': '如果您是……'
},
// AffiliateHowItWorksSection
'affiliate.howItWorks.heading': {
en: 'How it works',
'zh-CN': '运作方式'
},
// AffiliateBenefitsSection
'affiliate.benefits.heading': {
en: 'Why ComfyUI for affiliate creators',
'zh-CN': '为什么联盟创作者选择 ComfyUI'
},
// AffiliateBrandAssetsSection
'affiliate.assets.heading': {
en: 'Brand logos for your content',
'zh-CN': '可用于您内容的品牌 Logo'
},
'affiliate.assets.subheading': {
en: 'Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
'zh-CN': '横幅图、截图和宣传文案将在获批后于联盟仪表盘中提供。'
},
'affiliate.assets.downloadLabel': {
en: 'Download zip',
'zh-CN': '下载压缩包'
},
// AffiliateFAQSection
'affiliate.faq.heading': {
en: 'Frequently asked questions',
'zh-CN': '常见问题'
},
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
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.',
'zh-CN':
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
},
'affiliate-landing.cta.apply': {
en: 'Apply Now',
'zh-CN': 'Apply Now'
},
'affiliate-landing.cta.applyAriaLabel': {
en: 'Apply Now (opens in new tab)',
'zh-CN': 'Apply Now (opens in new tab)'
'为您推荐的每个 Comfy Cloud 订阅赚取 30% 持续佣金,连续 3 个月。立即申请成为 Comfy 合作伙伴。'
},
// Hero
'affiliate-landing.hero.heading': {
en: 'Become a Comfy Partner',
'zh-CN': 'Become a Comfy Partner'
},
'affiliate-landing.hero.subheading': {
en: 'Earn 30% Commission for 3 Months.',
'zh-CN': 'Earn 30% Commission for 3 Months.'
},
'affiliate-landing.hero.body': {
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
'zh-CN':
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
},
'affiliate-landing.hero.highlight.0': {
en: '30% recurring commission for 3 months',
'zh-CN': '30% recurring commission for 3 months'
},
'affiliate-landing.hero.highlight.1': {
en: '60-day cookie window',
'zh-CN': '60-day cookie window'
},
'affiliate-landing.hero.highlight.2': {
en: '$100 minimum payout',
'zh-CN': '$100 minimum payout'
},
'affiliate-landing.hero.highlight.3': {
en: 'Monthly payouts',
'zh-CN': 'Monthly payouts'
},
// Trust band
'affiliate-landing.trust.label': {
en: 'Trusted by 2M+ creators worldwide',
'zh-CN': 'Trusted by 2M+ creators worldwide'
},
// How it works
'affiliate-landing.how.heading': {
en: 'How it works',
'zh-CN': 'How it works'
},
'affiliate-landing.how.step.0.title': {
en: 'Apply.',
'zh-CN': 'Apply.'
},
'affiliate-landing.how.step.0.body': {
en: 'Submit a quick form. Most applicants approved same day.',
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
},
'affiliate-landing.how.step.1.title': {
en: 'Share.',
'zh-CN': 'Share.'
},
'affiliate-landing.how.step.1.body': {
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
'zh-CN':
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
},
'affiliate-landing.how.step.2.title': {
en: 'Earn.',
'zh-CN': 'Earn.'
},
'affiliate-landing.how.step.2.body': {
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
'zh-CN':
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
},
// Who we're looking for
'affiliate-landing.audience.heading': {
en: "Who we're looking for",
'zh-CN': "Who we're looking for"
},
'affiliate-landing.audience.item.0': {
en: 'ComfyUI tutorial creators and workflow builders',
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
},
'affiliate-landing.audience.item.1': {
en: 'AI tool reviewers on YouTube, TikTok, blogs',
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
},
'affiliate-landing.audience.item.2': {
en: 'Tech bloggers covering AI creative tools',
'zh-CN': 'Tech bloggers covering AI creative tools'
},
'affiliate-landing.audience.item.3': {
en: 'Newsletter operators in the AI/creative space',
'zh-CN': 'Newsletter operators in the AI/creative space'
},
'affiliate-landing.audience.item.4': {
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
'zh-CN':
'Anyone with an audience interested in AI image, video, or 3D generation'
},
// Program details
'affiliate-landing.details.heading': {
en: 'Program details',
'zh-CN': 'Program details'
},
'affiliate-landing.details.headerLabel': {
en: 'Detail',
'zh-CN': 'Detail'
},
'affiliate-landing.details.headerValue': {
en: 'Value',
'zh-CN': 'Value'
},
'affiliate-landing.details.row.0.label': {
en: 'Commission rate',
'zh-CN': 'Commission rate'
},
'affiliate-landing.details.row.0.value': {
en: '30% recurring',
'zh-CN': '30% recurring'
},
'affiliate-landing.details.row.1.label': {
en: 'Commission duration',
'zh-CN': 'Commission duration'
},
'affiliate-landing.details.row.1.value': {
en: '3 months',
'zh-CN': '3 months'
},
'affiliate-landing.details.row.2.label': {
en: 'Cookie window',
'zh-CN': 'Cookie window'
},
'affiliate-landing.details.row.2.value': {
en: '60 days',
'zh-CN': '60 days'
},
'affiliate-landing.details.row.3.label': {
en: 'Eligible products',
'zh-CN': 'Eligible products'
},
'affiliate-landing.details.row.3.value': {
en: 'Comfy Cloud paid subscription plans',
'zh-CN': 'Comfy Cloud paid subscription plans'
},
'affiliate-landing.details.row.4.label': {
en: 'Payouts',
'zh-CN': 'Payouts'
},
'affiliate-landing.details.row.4.value': {
en: 'Monthly, within first 10 business days',
'zh-CN': 'Monthly, within first 10 business days'
},
'affiliate-landing.details.row.5.label': {
en: 'Minimum payout',
'zh-CN': 'Minimum payout'
},
'affiliate-landing.details.row.5.value': {
en: '$100',
'zh-CN': '$100'
},
// Brand assets
'affiliate-landing.assets.heading': {
en: 'Brand assets',
'zh-CN': 'Brand assets'
},
'affiliate-landing.assets.subheading': {
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
'zh-CN':
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
},
'affiliate-landing.assets.downloadLabel': {
en: 'Download',
'zh-CN': 'Download'
},
'affiliate-landing.assets.tile.logo-horizontal.title': {
en: 'Comfy logo (horizontal)',
'zh-CN': 'Comfy logo (horizontal)'
},
'affiliate-landing.assets.tile.logomark.title': {
en: 'Comfy logomark',
'zh-CN': 'Comfy logomark'
},
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
en: 'Comfy full logo (yellow)',
'zh-CN': 'Comfy full logo (yellow)'
},
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
en: 'Comfy full logo (ink)',
'zh-CN': 'Comfy full logo (ink)'
},
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
en: 'Amplified logo mark',
'zh-CN': 'Amplified logo mark'
},
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
en: 'Dimensional logo mark',
'zh-CN': 'Dimensional logo mark'
},
'affiliate-landing.assets.tile.color-combo-yellow.title': {
en: 'Color combo (yellow)',
'zh-CN': 'Color combo (yellow)'
},
'affiliate-landing.assets.tile.color-combo-ink.title': {
en: 'Color combo (ink)',
'zh-CN': 'Color combo (ink)'
},
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
'affiliate-landing.faq.heading': {
en: 'Questions',
'zh-CN': 'Questions'
},
'affiliate-landing.faq.1.q': {
en: 'How do I track my referrals?',
'zh-CN': 'How do I track my referrals?'
},
'affiliate-landing.faq.1.a': {
en: 'Real-time dashboard via our partner portal.',
'zh-CN': 'Real-time dashboard via our partner portal.'
},
'affiliate-landing.faq.2.q': {
en: 'What plans qualify?',
'zh-CN': 'What plans qualify?'
},
'affiliate-landing.faq.2.a': {
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
},
'affiliate-landing.faq.3.q': {
en: 'How long does approval take?',
'zh-CN': 'How long does approval take?'
},
'affiliate-landing.faq.3.a': {
en: 'Most applications approved within 24 hours.',
'zh-CN': 'Most applications approved within 24 hours.'
},
'affiliate-landing.faq.4.q': {
en: 'When do I get paid?',
'zh-CN': 'When do I get paid?'
},
'affiliate-landing.faq.4.a': {
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
'zh-CN':
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
},
'affiliate-landing.faq.5.q': {
en: 'What happens if my referral upgrades or downgrades?',
'zh-CN': 'What happens if my referral upgrades or downgrades?'
},
'affiliate-landing.faq.5.a': {
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
'zh-CN':
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
},
'affiliate-landing.faq.6.q': {
en: 'Can I use coupon codes?',
'zh-CN': 'Can I use coupon codes?'
},
'affiliate-landing.faq.6.a': {
en: 'Yes. We support both tracking links and unique coupon codes.',
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
},
'affiliate-landing.faq.7.q': {
en: 'What if my referral uses an ad blocker?',
'zh-CN': 'What if my referral uses an ad blocker?'
},
'affiliate-landing.faq.7.a': {
en: 'We use server-side tracking, so conversions are tracked regardless.',
'zh-CN':
'We use server-side tracking, so conversions are tracked regardless.'
},
'affiliate-landing.faq.8.q': {
en: 'What assets do you provide?',
'zh-CN': 'What assets do you provide?'
},
'affiliate-landing.faq.8.a': {
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
'zh-CN':
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
},
// Footer CTA
'affiliate-landing.footerCta.heading': {
// AffiliateCtaSection
'affiliate.cta.heading': {
en: 'Ready to start earning?',
'zh-CN': 'Ready to start earning?'
'zh-CN': '准备好开始赚取佣金了吗?'
},
'affiliate-landing.footerCta.termsLink': {
'affiliate.cta.apply': {
en: 'APPLY NOW',
'zh-CN': '立即申请'
},
'affiliate.cta.termsLabel': {
en: 'Read the affiliate program terms',
'zh-CN': 'Read the affiliate program terms'
'zh-CN': '阅读联盟计划条款'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -1,44 +1,34 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import AudienceSection from '../../components/affiliates/AudienceSection.vue'
import BrandAssetsSection from '../../components/affiliates/BrandAssetsSection.vue'
import FooterCtaSection from '../../components/affiliates/FooterCtaSection.vue'
import HeroSection from '../../components/affiliates/HeroSection.vue'
import HowItWorksSection from '../../components/affiliates/HowItWorksSection.vue'
import ProgramDetailsSection from '../../components/affiliates/ProgramDetailsSection.vue'
import TrustBandSection from '../../components/affiliates/TrustBandSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_HEADING_KEY,
AFFILIATE_FAQ_PREFIX
} from '../../components/affiliates/affiliateFaqs'
import type { Locale, TranslationKey } from '../../i18n/translations'
import AudienceSection from '../../templates/affiliate/AudienceSection.vue'
import BenefitsSection from '../../templates/affiliate/BenefitsSection.vue'
import BrandAssetsSection from '../../templates/affiliate/BrandAssetsSection.vue'
import CtaSection from '../../templates/affiliate/CtaSection.vue'
import FAQSection from '../../templates/affiliate/FAQSection.vue'
import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
const locale: Locale =
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => {
const n = i + 1
return {
'@type': 'Question',
name: t(`${AFFILIATE_FAQ_PREFIX}.${n}.q` as TranslationKey, locale),
acceptedAnswer: {
'@type': 'Answer',
text: t(`${AFFILIATE_FAQ_PREFIX}.${n}.a` as TranslationKey, locale)
}
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
})
}))
}
---
<BaseLayout
title={t('affiliate-landing.page.title', locale)}
description={t('affiliate-landing.page.description', locale)}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
@@ -48,18 +38,11 @@ const faqJsonLd = {
/>
</Fragment>
<HeroSection locale={locale} client:load />
<TrustBandSection locale={locale} />
<HowItWorksSection locale={locale} />
<AudienceSection locale={locale} />
<ProgramDetailsSection locale={locale} />
<BrandAssetsSection locale={locale} />
<FAQSection
locale={locale}
headingKey={AFFILIATE_FAQ_HEADING_KEY}
faqPrefix={AFFILIATE_FAQ_PREFIX}
faqCount={AFFILIATE_FAQ_COUNT}
client:load
/>
<FooterCtaSection locale={locale} client:load />
<HeroSection />
<HowItWorksSection />
<AudienceSection />
<BenefitsSection />
<BrandAssetsSection />
<FAQSection client:visible />
<CtaSection />
</BaseLayout>

View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
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
modelName="Grok Imagine"
ctaHref="/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy — Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />
<ContentSection prefix="desktop_privacy" client:load />
</BaseLayout>

View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="模型 — Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection
locale="zh-CN"
modelName="Grok Imagine"
ctaHref="/zh-CN/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ContentSection from '../../../components/common/ContentSection.vue'
import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 — Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import ChecklistSplit01 from '../../components/blocks/ChecklistSplit01.vue'
import { affiliateAudienceCriteria } from '../../data/affiliateAudience'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const criteria = affiliateAudienceCriteria.map((criterion) => ({
id: criterion.id,
label: criterion.label[locale]
}))
</script>
<template>
<ChecklistSplit01
:heading="t('affiliate.audience.heading', locale)"
:subheading="t('affiliate.audience.subheading', locale)"
:criteria="criteria"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import BenefitsGrid01 from '../../components/blocks/BenefitsGrid01.vue'
import { externalLinks } from '../../config/routes'
import { affiliateBenefits } from '../../data/affiliateBenefits'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const benefits = affiliateBenefits.map((benefit) => ({
id: benefit.id,
description: benefit.description[locale]
}))
</script>
<template>
<BenefitsGrid01
:heading="t('affiliate.benefits.heading', locale)"
:benefits="benefits"
:primary-cta="{
label: t('affiliate.hero.apply', locale),
href: externalLinks.affiliateApplicationForm,
target: '_blank'
}"
/>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import BrandAssetsGrid01 from '../../components/blocks/BrandAssetsGrid01.vue'
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const assets = affiliateBrandAssets.map((asset) => ({
id: asset.id,
title: asset.title[locale],
download: asset.download,
preview: asset.preview
}))
</script>
<template>
<BrandAssetsGrid01
:heading="t('affiliate.assets.heading', locale)"
:subheading="t('affiliate.assets.subheading', locale)"
:download-label="t('affiliate.assets.downloadLabel', locale)"
:assets="assets"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<CtaCenter01
:heading="t('affiliate.cta.heading', locale)"
:primary-cta="{
label: t('affiliate.cta.apply', locale),
href: externalLinks.affiliateApplicationForm,
target: '_blank'
}"
:terms-link="{
label: t('affiliate.cta.termsLabel', locale),
href: routes.affiliateTerms
}"
/>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqs = affiliateFaqs.map((faq) => ({
id: faq.id,
question: faq.question[locale],
answer: faq.answer[locale]
}))
</script>
<template>
<FAQSplit01 :heading="t('affiliate.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import { externalLinks } from '@/config/routes.ts'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<HeroSplit01
:badge-text="t('affiliate.hero.label', locale)"
:title-highlight="t('affiliate.hero.headingHighlight', locale)"
:title="t('affiliate.hero.headingMuted', locale)"
:features="[
t('affiliate.hero.feature1', locale),
t('affiliate.hero.feature2', locale),
t('affiliate.hero.feature3', locale),
t('affiliate.hero.feature4', locale)
]"
:primary-cta="{
label: t('affiliate.hero.apply', locale),
href: externalLinks.affiliateApplicationForm
}"
video-autoplay
video-loop
video-hide-controls
video-src="https://media.comfy.org/website/affiliates/rainlit-ronin_compressed.mp4"
:image-alt="t('affiliate.hero.imageAlt', locale)"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import StepsGrid01 from '../../components/blocks/StepsGrid01.vue'
import { affiliateHowItWorksSteps } from '../../data/affiliateHowItWorks'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = affiliateHowItWorksSteps.map((step) => ({
id: step.id,
label: step.label[locale],
description: step.description[locale]
}))
</script>
<template>
<StepsGrid01
:heading="t('affiliate.howItWorks.heading', locale)"
:steps="steps"
/>
</template>

View File

@@ -0,0 +1,115 @@
{
"id": "test-missing-model-promoted-widget",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -100,10 +100,7 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
async dragElementBy(element: Locator, { x, y }: { x?: number; y?: number }) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')

View File

@@ -69,11 +69,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
await expect(expansionContent).toBeHidden()
})
})
test.describe('Responsive Layout', () => {
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await page.setViewportSize({ width: 480, height: 800 })
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
await expect(
row.getByRole('button', { name: /Add new keybinding/i })
).toBeInViewport()
const hasHorizontalScroll = await page
.locator('.keybinding-panel .p-datatable-table-container')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
expect(hasHorizontalScroll).toBe(false)
})
test('Keybinding column compresses with width while actions stay reachable', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const keybindingList = row.getByTestId('keybinding-list')
await expect(keybindingList).toBeVisible()
const listWidthAt = async (viewportWidth: number) => {
await page.setViewportSize({ width: viewportWidth, height: 800 })
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
}
const wideWidth = await listWidthAt(1280)
const narrowWidth = await listWidthAt(560)
expect(narrowWidth).toBeLessThan(wideWidth)
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
})
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
type Load3dImageInput = {
image: string
mask: string
normal: string
recording: string
}
type PromptBody = {
prompt?: Record<
string,
{ class_type?: string; inputs?: Record<string, unknown> }
>
}
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
const prompt = (body as PromptBody).prompt ?? {}
const node = prompt[nodeId]
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
const input = node!.inputs!.image as Load3dImageInput
expect(typeof input.image).toBe('string')
expect(typeof input.recording).toBe('string')
return input
}
test.describe('Load3D serialize cache', () => {
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
comfyPage,
load3d
}) => {
const exec = new ExecutionHelper(comfyPage)
let firstBody: unknown
await exec.run({
onPromptRequest: (body) => {
firstBody = body
}
})
const firstInput = getLoad3dImageInput(firstBody, '1')
expect(firstInput.recording).toBe('')
await load3d.recordingButton.click()
await expect(load3d.stopRecordingButton).toBeVisible()
let secondBody: unknown
await exec.run({
onPromptRequest: (body) => {
secondBody = body
}
})
const secondInput = getLoad3dImageInput(secondBody, '1')
expect(
secondInput.image,
'after starting a recording, the next queue must re-capture ' +
'(image filename must change) so the recording is not dropped'
).not.toBe(firstInput.image)
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
test('Should keep execution errors matching the search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await searchInput.fill('Execution failed')
await expect(runtimePanel).toHaveCount(0)
await expect(runtimePanel).toBeVisible()
})
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
test('Should show runtime error log in the execution error group', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
await expect(runtimePanel).toContainText('Error log')
})
})

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Textarea widget font size',
{ tag: ['@widget', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
})
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
comfyPage
}) => {
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
await expect(textarea).toBeVisible()
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('14px')
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('22px')
})
}
)

View File

@@ -8,6 +8,7 @@ import {
} from '@e2e/fixtures/ComfyPage'
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
@@ -1231,3 +1232,41 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
}).toPass({ timeout: 5000 })
})
})
test(
'Fast disconnection support',
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
await comfyMouse.click(100, 100)
}
const isConnected = () => comfyPage.vueNodes.isSlotConnected(slot)
await expect.poll(isConnected).toBe(false)
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
}
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)
await performDisconnect(ksampler.getSlot('positive'), false)
})
await test.step('Create subgraph', async () => {
await ksampler.title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await comfyPage.vueNodes.enterSubgraph()
})
await test.step('Disconnection with subgraph IO', async () => {
await performDisconnect(ksampler.getSlot('negative'), true)
await performDisconnect(ksampler.getSlot('latent_image'), false)
})
}
)

View File

@@ -174,10 +174,50 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await comfyMouse.dragElementBy(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await comfyMouse.dragElementBy(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -40,11 +40,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
const gutter = comfyPage.page.getByRole('separator')
await expect(gutter).toBeVisible()
await comfyMouse.resizeByDragging(gutter, { x: -200 })
await comfyMouse.dragElementBy(gutter, { x: -200 })
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
const intermediateWidth = await getWidth()
await comfyMouse.resizeByDragging(gutter, { x: 100 })
await comfyMouse.dragElementBy(gutter, { x: 100 })
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.9",
"version": "1.46.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -121,6 +121,7 @@
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--comfy-textarea-font-size: 10px;
--input-text: #ddd;
--descrip-text: #999;
--drag-text: #ccc;

View File

@@ -28,13 +28,15 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelAssetSeedData,
CancelAssetSeedResponse,
CancelAssetSeedResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
@@ -57,11 +59,14 @@ export type {
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClientOptions,
CreateAssetData,
CreateAssetDownloadData,
CreateAssetDownloadError,
CreateAssetDownloadErrors,
CreateAssetDownloadResponse,
CreateAssetDownloadResponses,
CreateAssetError,
CreateAssetErrors,
CreateAssetExportData,
CreateAssetExportError,
CreateAssetExportErrors,
@@ -72,6 +77,8 @@ export type {
CreateAssetFromHashErrors,
CreateAssetFromHashResponse,
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
@@ -208,6 +215,8 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
FreeMemoryData,
FreeMemoryResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
@@ -221,6 +230,9 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
GetAssetTagHistogramData,
GetAssetTagHistogramError,
GetAssetTagHistogramErrors,
@@ -259,6 +271,9 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
@@ -305,6 +320,18 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetI18nData,
GetI18nResponse,
GetI18nResponses,
GetInternalFolderPathsData,
GetInternalFolderPathsResponse,
GetInternalFolderPathsResponses,
GetInternalLogsData,
GetInternalLogsRawData,
GetInternalLogsRawResponse,
GetInternalLogsRawResponses,
GetInternalLogsResponse,
GetInternalLogsResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -356,10 +383,7 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -389,8 +413,26 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceByPathData,
GetOAuthProtectedResourceByPathError,
GetOAuthProtectedResourceByPathErrors,
GetOAuthProtectedResourceByPathResponse,
GetOAuthProtectedResourceByPathResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -427,11 +469,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -447,6 +489,7 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -534,6 +577,11 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -642,6 +690,17 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -663,6 +722,21 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -687,6 +761,9 @@ export type {
PromptInfo,
PromptRequest,
PromptResponse,
PruneAssetsData,
PruneAssetsResponse,
PruneAssetsResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -732,6 +809,9 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -751,6 +831,8 @@ export type {
SubscribeResponse,
SubscribeResponse2,
SubscribeResponses,
SubscribeToLogsData,
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
@@ -771,11 +853,6 @@ export type {
UpdateAssetErrors,
UpdateAssetResponse,
UpdateAssetResponses,
UpdateAssetTagsData,
UpdateAssetTagsError,
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
@@ -799,11 +876,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
@@ -821,11 +898,6 @@ export type {
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,
UploadAssetData,
UploadAssetError,
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,

File diff suppressed because it is too large Load Diff

View File

@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
})
/**
* Offset/limit-based pagination metadata included in list responses.
* Pagination metadata included in list responses. Supports both legacy
* offset/limit pagination and cursor-based pagination. When cursor-based
* pagination is used, `next_cursor` is the primary pagination token and
* `offset`/`total` may be zero.
*
*/
export const zPaginationInfo = z.object({
offset: z.number().int().gte(0),
limit: z.number().int().gte(1),
total: z.number().int().gte(0),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']).optional(),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
tags: z.array(z.string()).optional(),
mime_type: z.string().optional(),
user_metadata: z.record(z.unknown()).optional(),
updated_at: z.string().datetime()
job_id: z.string().uuid().nullish(),
updated_at: z.string().datetime(),
file_path: z.string().nullish()
})
/**
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
/**
* Response returned after successfully queuing a workflow prompt.
*/
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
sort_by: z.enum(['create_time', 'execution_time']).optional(),
sort_order: z.enum(['asc', 'desc']).optional(),
after: z.string().optional(),
offset: z.number().int().gte(0).optional().default(0),
limit: z.number().int().gte(1).lte(1000).optional().default(100)
})
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
hash: z.string().optional(),
after: z.string().optional()
})
.optional()
})
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
*/
export const zListAssetsResponse2 = zListAssetsResponse
export const zUploadAssetData = z.object({
export const zCreateAssetData = z.object({
body: z.object({
url: z.string().url(),
name: z.string(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_id: z.string().uuid().optional()
file: z.string(),
hash: z
.string()
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
.optional(),
tags: z.string().optional(),
id: z.string().uuid().optional(),
preview_id: z.string().uuid().optional(),
name: z.string().optional(),
mime_type: z.string().optional(),
user_metadata: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Asset already exists (returned existing asset)
* Asset already existed for this user (deduplicated by content hash); the
* existing asset is returned with created_new=false.
*
*/
export const zUploadAssetResponse = zAssetCreated
export const zCreateAssetResponse = zAssetCreated
export const zCreateAssetFromHashData = z.object({
body: z.object({
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
name: z.string().optional(),
tags: z.array(z.string()).min(1),
mime_type: z.string().optional(),
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference already exists (returned existing)
* Asset reference already existed for this user (deduplicated by content
* hash); the existing asset is returned with created_new=false.
*
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
naming_strategy: z
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
include_previews: z.boolean().optional().default(false)
}),
path: z.never().optional(),
query: z.never().optional()
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
})
/**
* Asset deleted successfully
* Asset record deleted successfully
*/
export const zDeleteAssetResponse = z.void()
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
*/
export const zAddAssetTagsResponse = zTagsModificationResponse
export const zUpdateAssetTagsData = z.object({
body: z.object({
add: z.array(z.string()).optional(),
remove: z.array(z.string()).optional()
}),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Tags updated successfully
*/
export const zUpdateAssetTagsResponse = zTagsModificationResponse
export const zListTagsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
export const zGetSettingByIdData = z.object({
body: z.never().optional(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
/**
* Setting value response
*/
export const zGetSettingByKeyResponse = z.object({
export const zGetSettingByIdResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByKeyData = z.object({
export const zUpdateSettingByIdData = z.object({
body: z.unknown(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByKeyResponse = z.object({
export const zUpdateSettingByIdResponse = z.object({
value: z.unknown().optional()
})
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
type: z.string().optional()
})
export const zGetLogsData = z.object({
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthProtectedResourceByPathData = z.object({
body: z.never().optional(),
path: z.object({
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
}),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceByPathResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Config inserted successfully
*/
export const zInsertDynamicConfigResponse = z.object({
id: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
message: z.string().optional()
})
export const zSyncApiKeyData = z.object({
body: zSyncApiKeyRequest,
path: z.never().optional(),
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
}),
query: z.never().optional()
})
export const zGetEmbeddingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Embedding names
*/
export const zGetEmbeddingsResponse = z.array(z.string())
export const zFreeMemoryData = z.object({
body: z
.object({
unload_models: z.boolean().optional(),
free_memory: z.boolean().optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetI18nData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Nested map of locale to translation key-value pairs
*/
export const zGetI18nResponse = z.record(z.unknown())
export const zGetInternalFolderPathsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Map of folder type name to list of path entries
*/
export const zGetInternalFolderPathsResponse = z.record(
z.array(z.array(z.string()))
)
export const zGetInternalLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Log text
*/
export const zGetInternalLogsResponse = z.string()
export const zGetInternalLogsRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Structured log data
*/
export const zGetInternalLogsRawResponse = z.object({
entries: z
.array(
z.object({
t: z.number().optional(),
m: z.string().optional()
})
)
.optional(),
size: z
.object({
cols: z.number().int().optional(),
rows: z.number().int().optional()
})
.optional()
})
export const zSubscribeToLogsData = z.object({
body: z.object({
clientId: z.string(),
enabled: z.boolean()
}),
path: z.never().optional(),
query: z.never().optional()
})
export const zPruneAssetsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Prune result
*/
export const zPruneAssetsResponse = z.object({
status: z.string().optional(),
marked: z.number().int().optional()
})
export const zSeedAssetsData = z.object({
body: z
.object({
roots: z.array(z.string()).optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Seed started
*/
export const zSeedAssetsResponse = z.object({
status: z.string().optional()
})
export const zGetAssetSeedStatusData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan progress details (files scanned, total, status, etc.)
*/
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
export const zCancelAssetSeedData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan cancelled
*/
export const zCancelAssetSeedResponse = z.object({
status: z.string().optional()
})

View File

@@ -31,9 +31,9 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { useImageQuiet } from '@/composables/useImageQuiet'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -51,5 +51,5 @@ const {
alt?: string
}>()
const { error } = useImage(computed(() => ({ src, alt })))
const { error } = useImageQuiet(computed(() => ({ src, alt })))
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
@@ -46,7 +46,10 @@
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<div
class="min-w-0 overflow-x-hidden"
@contextmenu.capture="clearContextMenuTarget"
>
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
@@ -60,6 +63,7 @@
selection-mode="single"
context-menu
striped-rows
:table-style="{ tableLayout: 'fixed', width: '100%' }"
:pt="{
header: 'px-0'
}"
@@ -71,12 +75,11 @@
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
class="flex min-w-0 items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
@@ -103,53 +106,38 @@
<Column
field="keybindings"
:header="$t('g.keybinding')"
:style="{ width: '30%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="slotProps.data.isModified"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
<KeybindingList
:keybindings="slotProps.data.keybindings"
:is-modified="slotProps.data.isModified"
/>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:style="{ width: '16%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
<span class="block truncate" :title="slotProps.data.source">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<Column
field="actions"
header=""
:style="{ width: '9rem' }"
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
>
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<div
class="actions flex flex-row justify-end whitespace-nowrap"
>
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingList from './keybinding/KeybindingList.vue'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'

View File

@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeybindingList from './KeybindingList.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
nMoreKeybindings: '+ {count} more',
nMoreKeybindingsCompact: '+ {count}',
keybindingListAriaLabel: 'Keybindings: {combos}'
}
}
}
})
function makeKeybinding(key: string, ctrl = false, shift = false) {
return new KeybindingImpl({
commandId: 'test.cmd',
combo: { key, ctrl, shift }
})
}
function renderList(props: {
keybindings: KeybindingImpl[]
isModified?: boolean
}) {
return render(KeybindingList, {
props,
global: { plugins: [i18n] }
})
}
describe('KeybindingList', () => {
it('renders "-" placeholder when there are no keybindings', () => {
renderList({ keybindings: [] })
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
})
it('renders a single keybinding without any "more" badge', () => {
renderList({ keybindings: [makeKeybinding('A', true)] })
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-medium')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-compact')
).not.toBeInTheDocument()
})
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 1 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 1')
})
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
renderList({
keybindings: [
makeKeybinding('A', true),
makeKeybinding('B', true),
makeKeybinding('C', true)
]
})
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
'+ 1 more'
)
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 2 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 2')
})
it('uses a container query parent so the visible tier can adapt to width', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(screen.getByTestId('keybinding-list').className).toContain(
'@container/keybindings'
)
})
it('emits an accessible label listing all combos', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
})
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
expect(ariaText).toContain('Keybindings:')
expect(ariaText).toContain('Ctrl')
expect(ariaText).toContain('A')
expect(ariaText).toContain('Shift')
expect(ariaText).toContain('B')
})
})

View File

@@ -0,0 +1,74 @@
<template>
<span
v-if="keybindings.length > 0"
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
data-testid="keybinding-list"
>
<KeyComboDisplay
:key-combo="keybindings[0].combo"
:is-modified="isModified"
/>
<template v-if="keybindings.length >= 2">
<span
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
aria-hidden="true"
>
,
</span>
<KeyComboDisplay
class="hidden @[16rem]/keybindings:inline-flex"
:key-combo="keybindings[1].combo"
:is-modified="isModified"
/>
</template>
<span
v-if="keybindings.length > 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
data-testid="keybinding-list-more-wide"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
data-testid="keybinding-list-more-medium"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
data-testid="keybinding-list-more-compact"
>
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
</span>
<span class="sr-only" data-testid="keybinding-list-aria">
{{ ariaLabel }}
</span>
</span>
<span v-else>-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeyComboDisplay from './KeyComboDisplay.vue'
const { keybindings, isModified = false } = defineProps<{
keybindings: KeybindingImpl[]
isModified?: boolean
}>()
const { t } = useI18n()
const ariaLabel = computed(() => {
if (keybindings.length === 0) return ''
const combos = keybindings
.map((binding) => binding.combo.toString())
.join(', ')
return t('g.keybindingListAriaLabel', { combos })
})
</script>

View File

@@ -2,20 +2,12 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
@@ -105,58 +97,36 @@ const promptOnlyCard: ErrorCardData = {
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
export const SingleValidationError: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
card: singleErrorCard
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
card: multipleErrorsCard
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
card: runtimeErrorCard
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
card: promptOnlyCard
}
}

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