Compare commits

...

29 Commits

Author SHA1 Message Date
Robin Huang
3e1ede9a89 feat: add app:node_added telemetry event
Fires per user-initiated node add via LGraph.onNodeAdded, tagged with a
source discriminator (sidebar_drag, search_modal, paste, programmatic).
Bulk additions during workflow load are skipped — workflow_imported
already covers that population.

Source is threaded via a synchronous module-level flag set by
withNodeAddSource() at each call site, so addNodeOnGraph keeps its
existing signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 17:32:47 -07:00
Matt Miller
8657bff7d9 refactor(assets): read content hash via hash field, fall back to asset_hash (#12609)
## Summary

Reads the asset content hash from a new `hash` field, preferring it and
falling back to the existing `asset_hash` alias everywhere asset hashes
are consumed. This is the frontend half of converging the asset
content-hash field name onto `hash` (which the asset content-addressing
endpoints — `from-hash`, `hash/{hash}` — already use).

## Why this is safe to land now (order-independent)

- Against the current backend (emits only `asset_hash`), `hash` is
absent, so every read falls back to `asset_hash` → **byte-identical
behavior**.
- Once the backend emits `hash`, the FE uses it — which then lets the
backend drop `asset_hash` without breaking any read.

So this can merge independently of the backend; nothing is gated on it.

## What changed

- Add `hash` to the asset zod schema (`AssetItem`) and the local
`AssetRecord` type.
- Migrate all response reads of `asset_hash` → `hash ?? asset_hash`
(combo widget, widget select items, missing-media/missing-model scans,
media asset actions, preview util, metadata util, assets store).
- The `isCloud` storage-model branches (cloud = hash-as-filename, local
= name) keep their gate — only the field source changes. Removing those
conditionals is a separate behavioral change, not part of this rename.
- The `fetchAssets({ asset_hash })` request param is intentionally left
as-is; it flips to `hash` only after the backend accepts the new param
name.

## Test plan

- `vue-tsc --noEmit` — 0 errors
- `eslint` — clean
- `vitest run` on assets / missingMedia / missingModel — 775 tests pass

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-02 23:15:24 +00:00
nav-tej
ddf9308cd8 feat(website): add Affiliate Program link to footer Resources (#12600)
*PR Created by the Glary-Bot Agent*

---

Adds an **Affiliate Program** entry under the Resources column of the
site footer, positioned immediately after the YouTube link. Links to
`/affiliates` via the existing locale-invariant `routes.affiliates`
helper.

### Stacked on #12002

This PR is branched off `glary/affiliates-landing-page` (PR #12002), not
`main`, because:

- `routes.affiliates` and the `/affiliates` page itself are added by
#12002 and aren't on `main` yet.
- Linking to `/affiliates` from the footer before #12002 merges would
404 in production.

GitHub will auto-rebase this PR onto `main` once #12002 merges.
**Re-target this PR's base from `glary/affiliates-landing-page` to
`main` before reviewing for merge** (or simply merge #12002 first and
GitHub will switch the base for you).

### Diff

```
apps/website/src/components/common/SiteFooter.vue  +4
apps/website/src/i18n/translations.ts              +4
```

- New `footer.affiliateProgram` i18n key. English-only per the go-live
direction for affiliate-related copy (zh-CN mirrors EN to satisfy the
dictionary's `Record<Locale, string>` shape; matches how `nav.youtube`
and other proper-noun footer entries handle this).
- New `FooterLink` entry in `topColumns[1].links` (the Resources
column), after the YouTube entry. Internal route, so no `external:
true`.

### Local verification

- `pnpm typecheck`: 0 errors
- `pnpm build`: 380 pages, footer renders `Affiliate Program` →
`/affiliates` in both desktop and mobile layouts, on `/`, `/about`,
`/zh-CN/`, etc. Link ordering in Resources: `Blog → Discord → GitHub →
Docs → YouTube → Affiliate Program` (verified by parsing the built HTML)
- `pnpm test:unit`: passing (no SiteFooter unit test exists; it's
exercised through e2e/visual coverage)
- `pnpm lint`: no new warnings (3 pre-existing on unrelated files)
- `pnpm format:check`: clean
- `pnpm knip`: clean
- `pnpm exec tsx scripts/check-unused-i18n-keys.ts`: clean (new key is
used)

### Out of scope

- No new Linear ticket (this is a follow-up to the affiliate-program
go-live work tracked under FE-704).
- Header nav / mobile menu changes — only the footer was requested.

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-02 22:47:02 +00:00
Maanil Verma
775201f06d Feat/cloud onboarding redesign (#12442)
## Summary

Redesigns the cloud login and signup screens with a new split-screen
template featuring hero carousel on the right.

## Changes

- **What**:
- New `CloudHeroCarousel` component (product logo chips for Gemini,
Grok, Seedance)
- `CloudTemplate` restructured: removed video background and
`BaseViewTemplate` wrapper; new shell uses `bg-charcoal-750` /
`text-sand-500` and renders the carousel in the right column at `lg+`
- `CloudLoginView` and `CloudSignupView` restyled to match the new dark
shell (sand/azure/smoke tokens, `shadow-inset-highlight` OAuth buttons,
brand-yellow free-tier badge)
- `CloudSignInForm` submit button restyled to match the OAuth secondary
style
- `CloudLogo` switched from `<img>` to inline SVG with `currentColor` +
`aria-label`
- Three new logo components (`GeminiLogo`, `GrokLogo`, `SeedanceLogo`) —
inline SVG, `aria-hidden`, `currentColor`
- Theme tokens added in `style.css`: `--color-charcoal-750`,
`--color-sand-500`, `--shadow-inset-highlight`
- i18n keys added under `cloudHero.*` for carousel titles, descriptions,
and pager labels
- Hero image assets added under
`src/platform/cloud/onboarding/assets/hero/`


## Screenshots 

<img width="1470" height="838" alt="image"
src="https://github.com/user-attachments/assets/747bfc98-f605-4904-9a3f-3ba0de88573b"
/>

---------

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: Austin <austin@comfy.org>
2026-06-02 21:42:17 +00:00
nav-tej
f4088bcc63 feat(website): draft Affiliate Program landing page at /affiliates (#12002)
*PR Created by the Glary-Bot Agent*

---

Conversion-oriented English landing page that drives applications to the
Comfy Affiliate Program Google Form. Shipped behind `noindex` while copy
and assets are finalized — a follow-up PR will flip it indexable and add
the zh-CN locale.

Mirrors the type scale, brand tokens, and noindex/sitemap/robots
conventions from the [/affiliates/terms PR
(#11954)](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11954) so
the two pages feel like a set. The footer's "Read the affiliate program
terms" link points at `/affiliates/terms`, which lands with #11954.

## What's in the box

Sections (in render order): hero → trust band → how-it-works →
who-we're-looking-for → program details → brand assets → FAQ → footer
CTA.

- **i18n** — Copy lives under `affiliate-landing.*` in
`src/i18n/translations.ts` (en only; zh-CN values mirror en until a
localized version lands, so non-engineers can edit copy without touching
components).
- **Component reuse audit** — Reuses `common/FAQSection`,
`common/BrandButton`, `common/SectionHeader`, `common/SectionLabel`
verbatim. Sections without a clean primitive fit (split hero, trust
band, 3-step flow, audience list, details table, brand-asset grid,
footer CTA) render inline with brand tokens rather than forking new
shared components.
- **Brand assets** — Wired via a config object in
`components/affiliates/brandAssets.ts` so 728×90 / 300×250 / 160×600 /
1200×628 banners can be dropped in later without code changes. Live
tiles for `/icons/logo.svg` and `/icons/logomark.svg`; placeholder
"Coming soon" tiles for the four banner sizes.
- **CTA** — `Apply Now` → `https://forms.gle/RS8L2ttcuGap4Q1v6`, opens
in new tab with `target="_blank"` and `rel="noopener noreferrer"`. Hero
and footer-CTA both use it.
- **Hero media slot** — Wired as a named slot (`<slot name="media">`)
with a static node-graph SVG fallback, so a video can be dropped in
later via the page without component changes.
- **FAQ structured data** — Page emits `schema.org/FAQPage` JSON-LD so
it's ready for SEO when `noindex` flips off.
- **noindex** — `<meta name="robots" content="noindex, nofollow">` via
`BaseLayout.noindex`. `/affiliates` added to `NOINDEX_PATHNAMES` in
`astro.config.ts` (excluded from sitemap for both en and zh-CN locale
prefixes via the existing `LOCALE_PREFIXES.flatMap` pattern) and to
`robots.txt Disallow` under every UA block including the explicit AI-bot
overrides (GPTBot, ClaudeBot, OAI-SearchBot, ChatGPT-User, Claude-User,
Claude-SearchBot, PerplexityBot, Google-Extended).
- **BrandButton** — Added `rel?: string` prop so external CTAs across
the site can pair `target="_blank"` with `rel="noopener noreferrer"`.
- **Routes** — Added `affiliates`, `affiliateTerms`,
`affiliateApplicationForm` to `src/config/routes.ts`.

## Component-mapping (per AGENTS.md "no speculative variants" rule)

| Section | Where it lives | Reuse decision |
|---|---|---|
| Hero (split text+media) | `components/affiliates/HeroSection.vue` |
Inline. Existing `HeroSection`s are tightly coupled to specific i18n
namespaces (`cloud.hero.*` etc.) and `legal/HeroSection` is title-only.
|
| Trust band | `components/affiliates/TrustBandSection.vue` | Inline.
`common/SocialProofBarSection` hardcodes a 12-logo client strip; not a
fit for a single text claim. |
| How-it-works | `components/affiliates/HowItWorksSection.vue` +
`common/SectionHeader` | Inline cards. Existing `StepsSection`s are
tightly bound to product-specific namespaces. |
| Who we're looking for | `components/affiliates/AudienceSection.vue` +
`common/SectionHeader` | Inline icon-bullet list.
`pricing/PricingPlanFeatureList` is shaped for plan features with
descriptions. |
| Program details | `components/affiliates/ProgramDetailsSection.vue` +
`common/SectionHeader` | Inline `<table>` (desktop) + `<dl>` (mobile).
`product/shared/ReasonSection` is a long-form "left heading + right
list" layout, not a key/value table. |
| Brand assets | `components/affiliates/BrandAssetsSection.vue` +
`common/SectionHeader` | Inline grid (no existing download-grid
primitive). |
| FAQ | `common/FAQSection.vue` (verbatim reuse) | Reuse — the component
is already generic over `headingKey`/`faqPrefix`/`faqCount`. |
| Footer CTA | `components/affiliates/FooterCtaSection.vue` +
`common/BrandButton` | Inline. `customers/WhatsNextSection` is an
arrow-CTA card, not a centered band. |

## Responsive behaviour

- Hero stacks (text above media) under `lg` (1024px).
- Program Details collapses from `<table>` (md+) to a `<dl>` definition
list (sm).
- 3-step flow collapses from 3-up grid to vertical stack at `md`.
- Brand-asset grid steps from `lg:grid-cols-4` → `sm:grid-cols-2` →
`grid-cols-1`.

## Tests

- **Vitest unit** (`src/components/affiliates/affiliateLanding.test.ts`)
— asserts i18n key structure: every section prefix exists, sections
render in order, no internal/draft keys leak, every indexed item (hero
highlights, steps, audience items, details rows, FAQs) has an English
translation.
- **Playwright e2e** (`e2e/affiliates.spec.ts`) — desktop+mobile
render/visibility under `@smoke`, plus a non-smoke `desktop
interactions` describe that exercises the Apply Now click (popup + form
URL) and FAQ accordion toggle. Mobile asserts table → definition-list
collapse and no horizontal overflow on every section. FAQPage JSON-LD
presence verified end-to-end.

Local verification: `pnpm typecheck`, `pnpm test:unit`, `pnpm exec
playwright test e2e/affiliates.spec.ts`, `pnpm knip` — all green (40
unit, 11/11 e2e).

## Out of scope (intentional follow-ups)

- zh-CN locale + `/zh-CN/affiliates` route (omitted from `robots.txt`
until the route exists; sitemap exclusion already handles it generically
via `LOCALE_PREFIXES.flatMap`).
- Flipping `noindex` off once copy and assets are finalized.
- Dropping in the four banner SVGs (728×90, 300×250, 160×600, 1200×628)
— config object is wired, just edit
`components/affiliates/brandAssets.ts`.
- Hero video (slot is wired).
- Hooking the Apply Now CTA up to a tracked URL once the affiliate
platform is live.

## Cross-PR dependency

The footer "Read the affiliate program terms" link targets
`/affiliates/terms`, which is added by #11954. Until that PR lands, the
link 404s on this branch's preview deployment; once both merge to main,
the pair is complete.

## Screenshots

Desktop (1280w) render attached.

## Screenshots

![Desktop render of /affiliates landing page
(1280w)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/abdb5f46d61de76335da71e1dda13f2bb5d909244d19cb1c2d931634df264c81/pr-images/1778032141901-3962caf4-f3b6-45f4-9c1d-ef91e3b04441.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12002-feat-website-draft-Affiliate-Program-landing-page-at-affiliates-3586d73d365081cc9d78cf7ebe04747d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-02 21:41:47 +00:00
Dante
75e2c3ef9b fix: unify node selection source onto canonical selectedItems (FE-891) (#12592)
## Summary

Migrate the node-menu action path off the legacy
`app.canvas.selected_nodes` dict onto the canonical
`canvas.selectedItems` set so the Pin and Bypass context-menu options
read their selection state from one source.

## Changes

- **What**: `getSelectedNodes` and `getSelectedNodesShallow` in
`useSelectedLiteGraphItems` now derive from
`canvasStore.canvas?.selectedItems` filtered by `isLGraphNode` — the
same source the Pin label already uses via `useSelectionState`. The
Bypass label (`areAllSelectedNodesInMode`) and all node actions
(pin/collapse/bypass/mute/size/runBranch) now share that canonical
source. Subgraph expansion (`getSelectedNodes` → `collectFromNodes`) and
the no-expansion all-match mode toggle are preserved unchanged. The
accessor returns `[]` when the canvas is unavailable, preserving the
prior null-tolerance.
- **Breaking**: None. `app.canvas.selected_nodes` is untouched (still
maintained by LiteGraph, still read by extensions). For every selection
reachable through normal canvas interaction the two collections hold the
identical top-level node set and order, so behavior is preserved.

## Review Focus

- Resolves the dual-source-of-truth flagged by @DrJKL in #12500
(`useNodeMenuOptions.ts:99`): the Pin label read `selectedItems` while
the Bypass label read `selected_nodes`. Both now read `selectedItems`.
- Follow-up to #12500 (FE-720), which merged into `main`.
- **Scope is source-unification only.** Pre-existing semantic gaps are
left as follow-ups: Pin per-node-flip vs Bypass all-match toggle; Pin
expands subgraph children while Bypass does not; reroute-only selection
still renders no-op Pin/Bypass items.
- The legacy `selected_nodes` dict still has direct readers in the
extension layer — tracked in **FE-896** so the dict can eventually be
retired.

## Testing

Unit-only by design — this is a pure composable-layer source swap with
**no user-visible behavior change** (the node set and ordering are
identical to `selected_nodes` for every canvas-originated selection). An
E2E test would only re-exercise existing Pin/Bypass menu flows that
already pass unchanged. Coverage:

- `useSelectedLiteGraphItems.test.ts`: node accessors source from the
canonical `selectedItems`; a decoy node in the legacy `selected_nodes`
dict is asserted *not* to be read; canvas-unavailable returns `[]`
without throwing; subgraph expansion + all-match toggle preserved.
- `useNodeMenuOptions.test.ts`: the Bypass label derives from
`selectedItems`.
- Red→green: `c0bfc94c0a` adds the canonical-source tests (fail against
the legacy-source base); `f7f7176683` migrates the source (all green).

Linear: FE-891
2026-06-02 19:02:20 +00:00
Alexander Brown
60db6e85bf chore: Remove unused tags, add a config option to prevent future unused tags (#12569)
Also updates the minor knip version.
2026-06-02 18:59:09 +00:00
Steven Tran
f61a3212a9 Updated Pr 12480 - fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding (#12599)
Summary

Moves posthog.reset(true) off the explicit logout action and onto the
user session-state transition (currentUser truthy → null), so PostHog
identity reset fires on every way a session ends — not just
button-initiated logout. Covers server-side token revocation, account
deletion, and cross-tab sign-out, which previously left the user
anonymous without ever resetting PostHog (the identity bleed this guards
against).
Changes

What: Replaces the trackLogout() hook with onUserLogout(() =>
posthog.reset(true)) in PostHogTelemetryProvider's post-init block, next
to the existing onUserResolved identify call. Removes trackLogout() from
the TelemetryProvider interface, TelemetryRegistry, and
PostHogTelemetryProvider (plus the shouldResetOnInit field), and removes
the call site in useAuthActions.logout()
Why state-driven: onUserLogout is watch(resolvedUserInfo, (user, prev)
=> prev && !user && cb()) — it fires on the truthy → null transition
regardless of how the session ended. Since persistence is
browserLocalPersistence, this catches token revocation, account
deletion, and cross-tab sign-out, none of which route through
useAuthActions
reset(true) still generates a fresh anonymous distinct_id — same
identity-bleed guarantee as the old hook, just triggered by the actual
invariant (session ended) rather than one path to it
Net simplification: the entire trackLogout plumbing goes away, and the
previous intentional-skip special case for reauth is no longer needed

Review Focus

Reauth recovery: createReauthenticationRecovery (useAuthActions.ts ~219)
signs out then immediately re-signs-in the same user. The watcher now
fires reset on the transient null and re-identifies the same user right
after — harmless, nothing happens in between, and the old
intentional-skip is no longer required
Pre-init window: the watcher attaches in the post-init .then(), so a
logout during the posthog-js dynamic-import window isn't caught.
Accepted intentionally — nothing is identified pre-init (identify runs
in the same post-init block), so there's no Person to bleed. The one
behavior dropped vs. the old code is the pre-init eventQueue = [] clear;
this only matters in a logout-then-different-login race inside the
import window, documented inline, and no pre-init hook is re-added

Tests: the 3 trackLogout tests are replaced with watcher-fires tests —
truthy → null triggers reset(true), and the reauth roundtrip
re-identifies the same user

---------

Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-02 10:35:30 -07:00
Steven Tran
705f36c81a Pr/12481 - fixed error (#12574)
Replaces sending Mixpanel-style "$set_once" in the USER_AUTH_COMPLETED
event with a PostHog people.set_once (fallback to people.set) call after
PostHog initialization.

Prevents telemetry from initializing / sending network calls in test and
E2E environments (detects import.meta.env.MODE === 'test' or VITE_E2E /
VITE_DISABLE_TELEMETRY), to avoid flaky CI sharded E2E failures.

Keeps event capture behavior and queuing logic intact; user-level
property is now set via PostHog people API instead of event payload.

---------

Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-02 10:20:33 -07:00
Alexander Brown
18810cfd6f fix: resolve pnpm lint warnings in test files (#12534)
## Summary

Resolve the 3 outstanding `pnpm lint` warnings in two test files so the
lint run is clean.

## Changes

- **What**:
-
[src/composables/useLoad3d.test.ts](src/composables/useLoad3d.test.ts):
Extract `vi.mocked(...).mock.calls` chains into a single-line
intermediate. The previous multi-line chain tripped an oxlint
`vitest/hoisted-apis-on-top` false-positive; the rule actually targets
`vi.mock`/`vi.hoisted`/`vi.doMock` but mis-flags split
`vi.mocked(...).mock.calls` expressions. All other call sites in the
same file already use the single-line form.
-
[src/platform/workspace/composables/useWorkspaceBilling.test.ts](src/platform/workspace/composables/useWorkspaceBilling.test.ts):
Drop the `{ ...originalLocation, href: ... }` spread when stubbing
`window.location`. `useWorkspaceBilling` only reads
`window.location.href`, so the spread of a `Location` class instance was
both unnecessary and triggered `typescript-eslint/no-misused-spread`.

## Review Focus

- The oxlint warning on `vi.mocked(...).mock.calls` looks like a rule
bug — confirm the workaround is acceptable rather than suppressing the
rule.
- Confirm there is no test that depends on `window.location` carrying
anything beyond `href` in `useWorkspaceBilling.test.ts` (verified: only
`href` is read in production code and assertions).

Verified locally: `pnpm lint` reports 0 warnings / 0 errors, and both
affected test files pass (121 tests).

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 16:07:33 +00:00
Dante
87aca15dc4 feat(dialog): migrate ConfirmDialog callers to Reka-UI (Phase 5) (#12502)
## Summary

Phase 5 of the dialog migration. Replaces the remaining PrimeVue
`useConfirm` / `<ConfirmDialog>` usage in the main frontend (`src/`)
with the `showConfirmDialog` helper added in Phase 1. Public API of
`useDialogService` / `dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-577](https://linear.app/comfyorg/issue/FE-577/phase-5-migrate-confirmdialog-callers-secretspanel)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, merged), #12182 (Phase 3, merged), #12403 (Phase 4, **stacked
PR base**)

> **Stacked on Phase 4**: this PR targets
`jaewon/fe-576-dialog-reka-migration-phase-4`. Rebase onto `main` after
#12403 lands.

## Changes

### before
<img width="1586" height="840" alt="phase4-before-primevue-confirm"
src="https://github.com/user-attachments/assets/2cb78cc2-7324-4349-b44b-98b5e696a83f"
/>

### after
<img width="1586" height="840" alt="phase5-after-reka-confirm"
src="https://github.com/user-attachments/assets/c092b026-828b-46aa-a4ff-f8611906f692"
/>

### `src/platform/secrets/components/SecretsPanel.vue`

Delete-secret confirmation routes through the Reka helper:

| Before | After |
| --- | --- |
| `useConfirm()` + `<ConfirmDialog group="secrets" />` +
`confirm.require({ acceptClass: 'p-button-danger', accept: () =>
deleteSecret(secret) })` | `showConfirmDialog({ headerProps,
props.promptText, footerProps: { confirmText: t('g.delete'),
confirmVariant: 'destructive', onCancel, onConfirm } })` with explicit
`dialogStore.closeDialog(dialog)` on both branches |

Header / message i18n keys (`secrets.deleteConfirmTitle`,
`secrets.deleteConfirmMessage`) unchanged. Confirm button now uses
`t('g.delete')` instead of the implicit PrimeVue "Yes" default, matching
the Reka helper convention used in `KeybindingPanel` / `AssetCard`.

### `src/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue`

Drops the vestigial `<ConfirmDialog />` mount and its import. The script
section never called `useConfirm()` — close/delete workflow flows were
already migrated to `dialogService.confirm()` (the Reka path) in Phase
1, so the dangling overlay served no consumer.

### Tests

- `src/platform/secrets/components/SecretsPanel.test.ts` **(new)** — 3
tests: helper invoked with destructive variant + correct i18n props,
`onConfirm` closes the dialog **with the exact helper handle** and calls
`deleteSecret`, `onCancel` closes with the handle without deleting. The
mock returns a stable `DIALOG_HANDLE` so a regression that passes the
wrong arg to `closeDialog` fails the suite.
- `src/components/sidebar/tabs/BaseWorkflowsSidebarTab.test.ts` —
removed the now-orphan `vi.mock('primevue/confirmdialog', ...)`.

## Verification

After this PR, the only remaining references to PrimeVue's confirm
system in `src/` are the global `ConfirmationService` registration in
`main.ts`, which is owned by Phase 6 (FE-578) alongside the broader
Dialog / CSS cleanup:

```
$ grep -rn 'primevue/useconfirm\|primevue/confirmdialog\|useConfirm\b' src --include='*.ts' --include='*.vue'
(no matches)
```

## Out of scope

**Phase 6 (FE-578) — main frontend cleanup**
- Remove `ConfirmationService` registration from `src/main.ts`.
- Remove PrimeVue `Dialog` imports from `GlobalDialog.vue` and its
remaining `<style>` overrides (including `.manager-dialog`).

**`apps/desktop-ui/` — not part of FE-577**
- `apps/desktop-ui/src/components/maintenance/TaskListPanel.vue` still
uses `useConfirm()` + `<ConfirmPopup />`, and
`apps/desktop-ui/src/main.ts` + `.storybook/preview.ts` still register
`ConfirmationService`. The desktop app is a separate workspace package
consuming the main frontend; its PrimeVue confirm usage is intentionally
**not** migrated here. Tracking separately (follow-up ticket).

**Renderer scope caveat**
- `showConfirmDialog()` does not currently pass `renderer: 'reka'` to
`dialogStore.showDialog`. The helper continues to render through the
PrimeVue branch in `GlobalDialog.vue` until the helper itself is flipped
(Phase 1/6 territory). This PR only routes callers **to** the helper —
flipping the helper's render path is intentionally out of scope.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings in unrelated
files: `useLoad3d.test.ts`, `useWorkspaceBilling.test.ts`)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent):
- `SecretsPanel.test.ts` — 3/3 (hardened to assert handle pass-through)
  - `BaseWorkflowsSidebarTab.test.ts` — 3/3
- `src/platform/secrets` + `src/components/sidebar/tabs` +
`src/components/dialog` — 178/178
- [x] Manual verification on `cloud.comfy.org` via local HTTPS dev
server (Settings → Secrets → delete flow, before & after side-by-side)
- [ ] CI Playwright matrix

## Public API impact

None. `useDialogService` / `dialogStore` surface is unchanged.

## Review focus

1. **Destructive variant choice** — `acceptClass: 'p-button-danger'`
(PrimeVue) → `confirmVariant: 'destructive'` (Reka helper). Same
red-affirmative semantic; visual match confirmed in screenshots above.
2. **Confirm label** — switched from PrimeVue's implicit "Yes" to
`t('g.delete')` for the delete flow, matching how `KeybindingPanel` /
`AssetCard` configure the helper.
3. **`BaseWorkflowsSidebarTab.vue` deletion** — verifying the
`<ConfirmDialog />` was genuinely vestigial: `grep useConfirm
src/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue` returns
nothing, and `workflowService.deleteWorkflow` / `closeWorkflow` route
through `dialogService.confirm()` (Reka path).
4. **desktop-ui scope** — see "Out of scope" above. If the team wants
desktop-ui folded into this phase, happy to extend the PR.

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 12:59:40 +00:00
Alexander Brown
162908a421 docs: ECS pattern survey appendix + PromotionStore cleanup (#12580)
## Summary

Add the ECS pattern survey appendix to ADR 0008's companion-documents
table, and drop stale `PromotionStore` references across architecture
docs to reflect the ADR 0009 removal.

## Changes

- **What**:
- New `docs/architecture/appendix-ecs-pattern-survey.md` — surveys
bitECS, miniplex, koota, ECSY, Bevy, and Thyseus: patterns adopted,
departed from, and when to revisit.
  - ADR 0008 companion table gains a row pointing at the new appendix.
- `docs/adr/0009-…/before-after-flows.md`,
`docs/architecture/ecs-target-architecture.md`,
`docs/architecture/entity-problems.md` — drop references to
`PromotionStore` / `usePromotionStore` (the legacy three-layer mechanism
is gone; promoted value widgets are now linked `SubgraphInput`s).
- `docs/architecture/subgraph-boundaries-and-promotion.md` — reframes
its "current mechanism" section as historical context with an explicit
"removed by ADR 0009" callout.
- **Breaking**: None — docs-only.

## Review Focus

Wording in the historical-context callout on
`subgraph-boundaries-and-promotion.md`. Everything else is a small
cleanup or a new standalone document.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 00:25:34 -07:00
Christian Byrne
c5cc09dbc1 feat: draft Affiliate Program Terms page at /affiliates/terms (#11954)
*PR Created by the Glary-Bot Agent*

---

Draft, `noindex`-gated legal page rendering the **Comfy.org Affiliate
Program Terms and Conditions** verbatim from the source document. Treats
the copy as legal-final-pending-signoff: lives in i18n translations so
legal/non-engineers can edit text without a code change, the page is
excluded from search engines (`<meta robots noindex>` + `robots.txt` +
sitemap filter), and a follow-up PR will flip it to indexable once legal
signs off.

## What this adds

- **Route:** `/affiliates/terms` (en) + `/zh-CN/affiliates/terms`
(zh-CN), matching the existing privacy-policy / terms-of-service
localization pattern.
- **Anchor IDs match the spec exactly:** `#1-program-overview`,
`#2-eligible-products`, …, `#11-miscellaneous` — stable deep-linking.
- **Sticky desktop TOC** with active-section highlighting
(IntersectionObserver, smooth scroll, header offset,
`prefers-reduced-motion` aware).
- **Collapsed `<details>` accordion TOC on mobile** that auto-closes
after a click.
- **Effective Date** is a single i18n key
(`affiliate-terms.effective-date`) — currently `[TBD]`. One config var,
consumed by the page footer.
- **Plain accessible legal-doc styling:** site design tokens, no
marketing flourish or gradients, narrow column for readable line length,
high-contrast text.
- **noindex everywhere:** robots `noindex,nofollow` meta via
`BaseLayout.noindex`, `Disallow: /affiliates/terms` added to every UA
block in `robots.txt` (including the AI-bot overrides), and the route
excluded from `@astrojs/sitemap` for both locales.

## Implementation notes

The website does not currently use MDX — it uses i18n translation keys +
Vue `ContentSection` components for legal pages. I followed that
established pattern (per AGENTS.md "Align with rest of subrepo on impl
and design") rather than introducing MDX integration just for this page.
Editing affiliates terms copy is a one-file change in
`src/i18n/translations.ts`, no code touch required, which satisfies the
"Nav or legal can edit without a code change" intent.

A new component `LegalContentSection.vue` is added (rather than reusing
`ContentSection.vue` directly) because legal docs need: (a) a
`<details>` mobile accordion TOC instead of the existing
horizontal-scroll `CategoryNav`, (b) a footer Effective Date, and (c) a
tighter block-type set (paragraph + bullet list only). Privacy /
Terms-of-Service pages are untouched.

## Skipped per spec

Per the original brief, "Competitive analysis" bullets and the "Open
questions for legal review" callout are internal-only and were not
copied into the i18n keys. There is a unit test asserting no key
matching `competitive-analysis|open-questions|legal-review` exists under
the `affiliate-terms.*` namespace.

## ⚠️ Flag for legal review

The source legal copy contains an internal contradiction that I copied
verbatim rather than silently editing:
- **Section 3 (Commission Structure)** says: *"Commission duration: 3
months from the referred customer's first paid subscription"*
- **Section 7 (Termination)** says: *"Commissions on referred customers
will cease at the time of termination, even if within the **12-month**
commission window"*

These two clauses imply different commission windows (3 months vs 12
months). I left the copy as-is for legal to resolve before the indexable
follow-up PR — flagging here so it doesn't get missed.

## Verification

- `pnpm typecheck` (root) — clean
- `pnpm typecheck:website` — clean (91 Astro files, 0 errors)
- `pnpm test:unit` (website) — 35/35 passing including 5 new unit tests
for the affiliate-terms section structure
- `pnpm exec playwright test affiliates-terms` — 8/8 passing (5 desktop
`@smoke`, 3 mobile `@mobile`)
- `pnpm format:check` — clean
- `pnpm build` — clean, both routes generated, sitemap correctly
excludes both locale variants
- All pre-commit hooks pass (stylelint + oxfmt + oxlint + eslint +
typecheck + typecheck:website + check-unused-i18n-keys)

## Manual verification

Screenshots embedded below from the local Astro preview build at desktop
(1280×900) and mobile (Pixel 5 / 393×852) viewports. The mobile
accordion's collapsed and expanded states are both captured.

## Sub-agent review

Ran the Oracle review against `main`. Initial pass flagged the
Last-updated/Effective-Date footer duplication and a missing zh-CN route
— both addressed in commit `8a1ce890d`. The 3-month vs 12-month
contradiction is left for legal (see above).

## Preview URL

The Vercel preview URL with the route appended will be posted as a PR
comment after CI completes:

`<vercel-preview-host>/affiliates/terms`

## Screenshots

![Desktop view at 1280px showing the Affiliate Terms hero, sticky 'On
this page' sidebar TOC with all 11 sections, and the first two sections
rendered with proper inline links and bolded defined
terms](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/27749b93a2505a1e163124b9a7a5d72763303bd73dbd488a0fed576999523401/pr-images/1777973250517-aa672747-ffa2-481e-a55d-3f1773905903.png)

![Mobile view at 393px showing the hero, the collapsed On this page
details accordion with chevron, and section 1 rendered cleanly within
the narrow
viewport](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/27749b93a2505a1e163124b9a7a5d72763303bd73dbd488a0fed576999523401/pr-images/1777973250852-a5373a7a-2428-4f07-9ad7-d18a13f3be08.png)

![Mobile view with the accordion expanded, showing all 11 section links
as tap targets and the active section highlighted in
yellow](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/27749b93a2505a1e163124b9a7a5d72763303bd73dbd488a0fed576999523401/pr-images/1777973251199-2ed02d90-9009-41ee-bf7a-2d681611dde5.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11954-feat-draft-Affiliate-Program-Terms-page-at-affiliates-terms-3576d73d36508121851ef666a8c4c537)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: glary-bot <glary-bot@comfy.org>
2026-06-02 03:18:37 +00:00
Alexander Brown
e566ec4ca3 refactor: relocate UUID and NodeId out of litegraph (#12581)
## Summary

Move the canonical `UUID` utilities and the `NodeId` alias up out of
`src/lib/litegraph/` so non-litegraph code can reference them without
crossing the litegraph layer boundary.

## Changes

- **What**:
- `src/lib/litegraph/src/utils/uuid.ts` → `src/utils/uuid.ts` (full
file: `UUID`, `zeroUuid`, `createUuidv4`).
- `NodeId` moves from `src/lib/litegraph/src/LGraphNode.ts` to
`src/world/entityIds.ts`. `LGraphNode.ts` re-exports it; the litegraph
barrel still re-exports `createUuidv4` / `UUID` so the package's public
surface is unchanged.
- All 22 importers updated to `@/utils/uuid` (both
`@/lib/litegraph/src/utils/uuid` and the litegraph-internal
`./utils/uuid` relative paths).
- Drops the two `import-x/no-restricted-paths` ESLint disables in
`src/world/entityIds.ts` that were waiting on these moves.
- **Breaking**: None — litegraph re-exports preserve backward
compatibility for downstream consumers.

## Review Focus

- Each importer's change is identical (`@/lib/litegraph/src/utils/uuid`
→ `@/utils/uuid`), generated by `sed`.
- `src/lib/litegraph/src/LGraphNode.ts` now does `import type { NodeId }
from '@/world/entityIds'` + `export type { NodeId }` — confirm this
satisfies the litegraph layer boundary rules.
- `src/world/entityIds.ts` defines `NodeId` locally as `number |
string`; no semantic change.

Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 03:11:15 +00:00
Alexander Brown
1e01c7128b chore: Add org prefix for team in CODEOWNERS (#12590)
https://github.com/orgs/community/discussions/22751
2026-06-02 02:26:03 +00:00
Comfy Org PR Bot
ee65074edc 1.46.7 (#12546)
Patch version increment to 1.46.7

**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-02 02:13:53 +00:00
Alexander Brown
3d8bb91069 Revert "feat: enrich App Mode telemetry with view_mode, workflow_id, and is_app" (#12583)
Reverts Comfy-Org/ComfyUI_frontend#12543
2026-06-01 18:30:21 -07:00
Dante
60a4dc3001 fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12500)
## Summary

Right-clicking a bypassed node showed two bypass-related items in the
Vue "More Options" context menu (FE-720):

- Plain `Bypass` from the legacy LiteGraph `getExtraMenuOptions` hook in
`litegraphService.ts`
- `Remove Bypass` (with `Ctrl+B` and an icon) from the Vue
`getBypassOption` composable

The Vue menu's exact-label deduplicator in `contextMenuConverter.ts`
collapsed the unbypassed case (both emit `Bypass` → Vue source wins) but
not the bypassed case (`Bypass` vs `Remove Bypass`), so the duplicate
leaked through whenever the node was bypassed.

### before
<img width="1920" height="958" alt="fe-720-before"
src="https://github.com/user-attachments/assets/ef001aca-d70e-4798-ac61-01cc34c31e44"
/>

### after
<img width="1920" height="958" alt="fe-720-after"
src="https://github.com/user-attachments/assets/d6d2bf4b-cb98-4b30-9dac-9bd4b68a7e36"
/>

#### single active node (KSampler)
<img width="1920" height="958" alt="fe-720-1-unbypassed-node-menu"
src="https://github.com/user-attachments/assets/bec9cd47-2f2d-4adb-b95b-266e7969a36c"
/>

#### single bypassed node (Load Checkpoint)
<img width="1920" height="958" alt="fe-720-2-bypassed-node-menu"
src="https://github.com/user-attachments/assets/91f80157-836d-4fce-adad-474f31baff04"
/>

#### KSampler + bypassed Load Checkpoint
<img width="1920" height="958" alt="fe-720-3-mixed-selection-menu"
src="https://github.com/user-attachments/assets/e4780b16-08e5-4f87-80e9-3ff65a5acdae"
/>

## Root cause

`src/services/litegraphService.ts` pushes a `Bypass` entry from its
legacy `getExtraMenuOptions` hook in addition to the Vue
`getBypassOption`. In Vue-menu mode both reach the menu; the exact-label
dedup in `contextMenuConverter.ts` only collapses them when the labels
match, which fails once the node is bypassed and the Vue side switches
to `Remove Bypass`.

## Fix

Add `Bypass` and `Remove Bypass` to the `HARD_BLACKLIST` in
`contextMenuConverter.ts`. The blacklist filters the legacy emission out
of the Vue conversion pipeline (`convertContextMenuToOptions`) before it
is ever merged, so Vue's `getBypassOption` is the single source of the
bypass item in every node state — no duplicate is created in the first
place. This is the established convention for legacy items that the Vue
menu replaces (`Properties`, `Colors`, `Shapes`, `Title`, `Mode`,
`Properties Panel`, `Copy (Clipspace)`); Bypass is the same category.

`litegraphService.ts` reverts to a plain `content: 'Bypass'` and no
longer imports `areAllSelectedNodesInMode` or i18n keys for this entry.

The Vue `getBypassOption` label is still derived from the same
selection-aware predicate (`areAllSelectedNodesInMode`) that
`toggleSelectedNodesMode` uses, so on mixed selections the label stays
in sync with the action — it shows `Bypass` when clicking would bypass
the rest, rather than `Remove Bypass`.

**Trade-off:** the classic LiteGraph canvas menu
(`Comfy.VueNodes.Enabled: false`) renders `litegraphService`'s options
directly without going through `convertContextMenuToOptions`, so it
shows a plain `Bypass` regardless of node state. This matches the pre-PR
behavior (the legacy push was already a hardcoded `Bypass`), so it is
not a regression.

## Considered and rejected

- **`equivalents` map** (`bypass: ['bypass', 'remove bypass']`) — would
collapse `Bypass` and `Remove Bypass` as synonyms, which is semantically
wrong: they are distinct actions that must stay distinguishable, and the
rule would also misfire on the unbypassed case. A converter test locks
in that they are not treated as equivalents.
- **State-aware label on the legacy push** (matching the Vue label so
the exact-label dedup collapses them) — works, and additionally gives
the classic canvas menu a state-aware label, but it couples
`litegraphService` to the selection predicate and i18n keys solely to
keep a downstream dedup load-bearing. `HARD_BLACKLIST` removes the
duplicate at the source instead of creating, converting, then collapsing
it. The only thing lost is the classic-menu state-aware label, which was
never present pre-PR.
- **Gating the legacy push on `Comfy.UseNewMenu === 'Disabled'`** — the
setting that selects the legacy vs Vue context menu is
`Comfy.VueNodes.Enabled`, not `Comfy.UseNewMenu` (an unrelated
top-menu-bar toggle). Gating on `UseNewMenu` would drop the Bypass entry
from the legacy canvas menu for the OSS default (`VueNodes.Enabled:
false` + `UseNewMenu: 'Top'`).
- **Suppressing the legacy callback via
`SUPPRESSED_LITEGRAPH_CALLBACKS`** — matches by callback identity and
adds cross-file coupling for what is a simple label-based filter that
`HARD_BLACKLIST` already expresses.

## Cleanups (review feedback)

- Removed the now-dead `NodeSelectionState.bypassed` field and its
producer (no consumers after the label switch).
- Replaced the `vue-i18n` mock in `useNodeMenuOptions.test.ts` with a
real `createI18n` instance per `docs/testing/vitest-patterns.md`;
removed a `ts-expect-error` via a typed hoisted `app` mock.
- Simplified `getSelectedNodeArray` to
`Object.values(app.canvas.selected_nodes ?? {})`.

## Tests

- `useSelectedLiteGraphItems.test.ts` — `areAllSelectedNodesInMode`:
all-bypassed → true, mixed → false, empty → false.
- `useNodeMenuOptions.test.ts` — Vue label is `Bypass` (active / mixed)
and `Remove Bypass` (all bypassed).
- `contextMenuConverter.test.ts` — the legacy `Bypass` push is filtered
by `HARD_BLACKLIST` so the Vue item is the only bypass entry (keeps
shortcut/source); `Bypass` and `Remove Bypass` are not treated as label
equivalents.
- `browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts` —
e2e regression: exactly one bypass-family item per node state.

Verified live on a bypassed Load Checkpoint: single `Remove Bypass` →
toggle un-bypasses → single `Bypass`; no duplicate, rest of the menu
intact.

- Fixes FE-720

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-02 01:12:55 +00:00
Alexander Brown
c4c1dfa58a Remove drag node test from interaction.spec.ts (#12579)
It's flaky.
2026-06-02 01:05:48 +00:00
AustinMroz
1938ba809b Track undo state on subgraph conversion (#12575)
When converting to subgraph, `beforeChange` and `afterChange` were being
called, but these functions exclusively called vestigial change handlers
that don't actually affect change tracking.

Consequentially, if you made a change to the graph (updating a widget),
converted a node to a subgraph using the selectionToolbox, and then
pushed Ctrl+Z before performing any other canvas interaction, it would
incorrectly undo the prior widget edit as well.

This is resolved by calling the important handlers directly. Adding them
to `beforeChange`/`afterChange` was considered, but caused breakage in
other functions (`connect`) which failed to even attempt symmetric calls
of the function.
2026-06-02 00:36:43 +00:00
Alexander Brown
9a86b33c77 fix: silence icon utility probe warnings (#12512)
## Summary

Silence false Iconify warning spam during Tailwind lint analysis while
preserving icon utility generation and real failure signals.

## Changes

- **What**: Local wrapper around `@iconify/tailwind4` that swallows the
noise from `better-tailwindcss/enforce-canonical-classes` probes.
Wrapper delegates icon resolution to upstream's `getDynamicCSSRules`;
only custom-comfy folder loading is reimplemented. `lucideStrokePlugin`
uses a `InvalidIconProbeError` sentinel so missing icons / missing deps
still surface.
- **Dependencies**: None.

## Background

Both upstreams have refused to address this:

- `better-tailwindcss`
([schoero/eslint-plugin-better-tailwindcss#377](https://github.com/schoero/eslint-plugin-better-tailwindcss/issues/377),
open) — owner declines to filter third-party warnings; says it should be
fixed in iconify.
- `@iconify/tailwind4`
([iconify/iconify#357](https://github.com/iconify/iconify/issues/357),
closed) — maintainer defends the `console.warn` as intentional; *"not
something plugin has control over"*.

Trigger path: `enforce-canonical-classes` → Tailwind
`canonicalizeCandidates()` → iconify `matchComponents` handler throws on
non-icon strings → upstream catches + `console.warn`.

## Review Focus

- Inline `loadComfyIconSet` in `iconifyDynamicPlugin.js` skips
`@iconify/tools`' `parseColors → currentColor` normalization. Verified
the comfy SVGs already use `currentColor` directly; multi-color icons
(bria, bytedance) keep their explicit fills as expected.
- `lucideStrokePlugin.js` catch is now type-discriminated — only
`InvalidIconProbeError` returns `{}`; `Cannot load icon set` and `Cannot
find icon` re-throw.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 21:57:13 +00:00
Matt Miller
faac0347aa fix(cloud/oauth): mint session cookie when resuming consent while already signed in (#12571)
## Problem

When a user is **already signed in** and a client (e.g. an MCP client)
starts the OAuth flow, the consent screen fails with:

> OAuth request failed. Please restart from the client app.

**Repro:** be signed in to Cloud, then trigger an OAuth authorization
from a client. Instead of the consent screen, the consent view renders
the generic error.

## Root cause

The consent challenge request (`GET /oauth/authorize`) is authenticated
by the Cloud **session cookie** — a distinct credential from the
Firebase client login that `isLoggedIn` reflects.

- The **post-login** resume path (`useOAuthPostLoginRedirect`) mints
that cookie via `createSessionOrThrow()` before navigating to consent.
- The **already-signed-in** path — the `cloud-login` / `cloud-signup`
`beforeEnter` guards in `onboardingCloudRoutes.ts`, which short-circuit
straight to consent when `isLoggedIn` is `true` — skipped that step.

With no session cookie the consent challenge request is unauthenticated,
so the server redirects it to login; `fetch` follows the redirect to an
HTML page, `response.json()` throws, and `OAuthConsentView` falls
through to `genericError`.

The `Comfy.Cloud.SessionCookie` extension's `createSession()` on
auth-resolve is fire-and-forget and races the redirect, so it doesn't
reliably cover this path.

## Fix

`oauthConsentRedirect()` now mints the session cookie
(`createSessionOrThrow()`) before redirecting an already-signed-in user
to consent — symmetric with the post-login path. Best-effort: on failure
it logs and still lands on the consent view so the user gets an
actionable message rather than a silently dropped flow.

## Test plan

- [x] New `onboardingCloudRoutes.test.ts`: resume → mints cookie then
routes to consent; no pending OAuth → `cloud-user-check` (no mint); mint
failure → still routes to consent so the view surfaces the error.
- [x] `vue-tsc` typecheck, eslint, oxlint clean.
- [ ] Manual: already signed in to Cloud, start OAuth from a client →
consent screen renders (no "OAuth request failed").
2026-06-01 21:53:50 +00:00
Shibo Zhou
71f4b28207 feat: enrich App Mode telemetry with view_mode, workflow_id, and is_app (#12543)
## Summary

Stamp App Mode telemetry with the properties needed to measure the App
Builder product metrics validly in PostHog. Three small, independent
enrichments on top of the App-Mode execution attribution.

## Changes

- **What**:
- `view_mode` on `execution_start` / `execution_success` /
`execution_error` (captured at queue time alongside `is_app_mode`). Lets
the North Star be `execution_success` where `view_mode='app'` — genuine
app runs, excluding `builder:arrange` builder-preview runs that bare
`is_app_mode` also counts.
- `workflow_id` on `app:workflow_saved` and `app:app_mode_opened`
(sources `workflow` / `template_url`) via a shared
`workflowTelemetryId()` helper; `storeJob` refactored onto it so save /
open / run events share one join key. Enables distinct-app counts,
activated apps (created → ≥1 successful run), and per-app quality.
- intrinsic `is_app` on `app:share_flow` `link_created` (from the
workflow's `initialMode`, not the share-time view) plus `workflow_id`;
`is_app` on `app:workflow_imported` / `opened` (from the loaded graph's
`extra.linearMode`). Enables virality by true app-ness and app-traffic
attribution.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- **The commits to review are the three after the foundation**:
`view_mode`, `workflow_id`, and `is_app`. The first commit in the diff
(`feat: attribute workflow executions to App Mode in telemetry`) is the
pre-existing foundation this builds on — its branch is not currently on
the remote, so this PR is based on `main` and carries it forward.
Reviewing per-commit is easiest.
- **Join-key consistency**: `workflowTelemetryId()` is the single
definition of the workflow id (`activeState.id ?? initialState.id`),
shared by the new save/open events and the existing execution events. A
divergence would silently break the created→run and opened→run joins.
Unit-tested.
- **Scope (YAGNI)**: `workflow_id` / `is_app` added only where a locked
metric consumes it — `share_flow` only on `link_created`;
`app_mode_opened` only on the `workflow` / `template_url` sources (not
`app_builder` / `keybind`).
- **No double serialize**: `app.ts` reuses a single
`rootGraph.serialize()` for both the `is_app` derivation and
`afterLoadNewGraph`.

Cloud-only (telemetry is tree-shaken from OSS builds). No UI changes.

---------

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-01 14:47:09 -07:00
imick-io
fef35e7dda fix(website): responsive fixes for pricing, product heroes, and cloud banner (#12570)
## Summary

Responsive polish: stack mobile pricing plans, rework the cloud product
hero so the illustration and text fit at all viewports, shrink the local
product hero illustration below xl, and reduce the cloud banner font
size on mobile.

## Changes

- **What**:
- `pricing/PriceSection.vue`: replace mobile tab-toggle pattern with
stacked plan cards; drop the `activePlanIndex` ref and tab buttons.
- `product/cloud/HeroSection.vue`: rework layout so illustration and
text fit across breakpoints (md/lg sizing, spacing, max-widths).
- `product/local/HeroSection.vue`: shrink illustration container below
xl (`max-w-xs`/`md:max-w-sm`/`lg:max-w-md`).
- `product/shared/CloudBannerSection.vue`: smaller font on mobile
(`text-sm` instead of `text-lg`).

## Review Focus

- Mobile pricing UX: confirm stacked cards are preferable to the prior
tab-toggle for our target viewports.
- Cloud hero layout at md/lg/xl breakpoints — check for overflow or
awkward spacing.

## Screenshots (if applicable)

<!-- Add before/after screenshots of the pricing page (mobile) and
product hero pages. -->

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-06-01 21:30:30 +00:00
nav-tej
e4d481f893 fix(telemetry): harden PostHog init — person_profiles, cookie_domain, before_send (#12479)
## Summary

Hardens PostHog initialization in the cloud app and website with three
missing config options identified during PostHog QA audit.

## Changes

- **What**: Adds `person_profiles: 'identified_only'`, `cookie_domain:
'.comfy.org'`, and `before_send` PII redaction hook to both PostHog init
calls
- **person_profiles**: Prevents anonymous Person records being created
in PostHog for pre-auth visitors — only creates Person after
`identify()` is called (matches website config which already had this)
- **cookie_domain**: Enables cross-subdomain session stitching across
`app.comfy.org`, `docs.comfy.org`, `www.comfy.org` — without this, each
subdomain creates a separate PostHog session
- **before_send**: Last-line PII guard — strips `email`, `prompt`,
`user_email`, `$email` from event properties before transmission. Placed
after `...serverConfig` spread so it cannot be overridden by remote
config.

## Review Focus

- `before_send` runs before GeoIP enrichment — deliberately does NOT
drop `$ip` since country-code targeting needs it for the pricing
experiment
- `person_profiles: 'identified_only'` + anonymous flag evaluation:
PostHog confirmed this still allows flag eval via `$anon_distinct_id`;
stitched retroactively on `identify()`
- `serverConfig` spread is before the three new fields — these take
precedence over any remote config values (intentional for
security/correctness)

Closes MAR-233, MAR-235, MAR-236

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miles <miles@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-01 20:53:00 +00:00
Alexander Brown
da836cb681 chore: upgrade Astro to v6 (#12532)
## Summary

Upgrades the website app from Astro 5.10 to Astro 6.4.2.

## Changes

- **What**: Bumps `astro` (5.10 → 6.4.2), `@astrojs/vue` (5 → 6.0.1),
`@astrojs/sitemap` (3.7.1 → 3.7.3), and `@astrojs/check` (0.9.8 → 0.9.9)
in the workspace catalog. Refreshes `vite` in the lockfile from `8.0.0`
to `8.0.14` to clear a Vite/Rolldown "Not implemented" regression in dep
optimization that Astro v6 triggered on `8.0.0`.
- **Breaking**: None for our code. Audit against the [Astro v6 upgrade
guide](https://docs.astro.build/en/guides/upgrade-to/v6/) found no
breaking change applies:
  - Already on `<ClientRouter />` (no removed `<ViewTransitions />`)
- No content collections (`defineCollection`/`getCollection`) — legacy
removal n/a
- `getStaticPaths` functions don't access the deprecated `Astro` global
- No `<Image>`/`<Picture>`/`getImage()` usage — SVG rasterization &
client-throw n/a
  - No `import.meta.env.ASSETS_PREFIX`
- No file-extension endpoints (`*.xml.ts`/`*.json.ts`) so the
trailing-slash change is moot
  - No markdown content (heading-ID compat n/a)
- i18n config (`prefixDefaultLocale: false`, no
`redirectToDefaultLocale`) is compatible with the new
`redirectToDefaultLocale: false` default
  - No experimental flags enabled

## Review Focus

- The Vite lockfile bump from 8.0.0 → 8.0.14 inside the same `^8.0.0`
range. Without it, `astro check` on v6 hits `Not implemented` inside
Vite's `esbuildPlugin.generateBundle` via Rolldown.
- Sanity-check the audit above against any in-flight work that might add
content collections / `<Image>` etc.

## Verification

- `pnpm typecheck` → 0 errors
- `pnpm build` → 379 pages built
- `pnpm test:unit` → 85/85 passing
- `pnpm lint` → 0 errors (3 pre-existing warnings)
2026-06-01 20:38:13 +00:00
Deep Mehta
a549bd0123 fix: add static fallback favicon to index.html (#12537)
## Summary

cloud.comfy.org (and any route before the graph view mounts, e.g. the
login screen) renders no favicon. Add a static fallback so a favicon
always shows.

## Changes

- **What**: The favicon is only ever set at runtime —
`useFavicon('/assets/favicon.ico')` in `LayoutDefault.vue` and the
progress frames in `useProgressFavicon.ts`. Before that JS runs (first
paint, login screen, routes that don't mount
`GraphView`/`LayoutDefault`), no `<link rel="icon">` exists, and the
browser's default `/favicon.ico` request hits the SPA catch-all and
returns HTML, so no favicon renders. Added a single static `<link
rel="icon" href="/assets/favicon.ico">` (asset already exists) to
`index.html`.
- **Breaking**: none.

## Review Focus

- Single icon link is intentional: vueuse `useFavicon` overwrites the
`href` of every `link[rel*="icon"]` at runtime (including
`apple-touch-icon`), so the progress-favicon animation assumes one
controllable icon link. Adding multiple static links (svg/png) would get
their `href` clobbered to the progress PNG mid-generation and render
inconsistently — so this PR deliberately adds just the `.ico`.
- The progress-favicon still works: vueuse updates this same link's
`href`.
- Path matches what the app already uses at runtime
(`/assets/favicon.ico`).

## Screenshots (if applicable)
2026-06-01 20:31:16 +00:00
Alexander Brown
593586bbeb chore: Update CODEOWNERS (Should we just delete it?) (#12568)
It's requiring a lot of "Skip check" merges.
2026-06-01 19:56:19 +00:00
Terry Jia
7df62ca75e feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12545)
## Summary
Two dedicated 3D viewer extensions for the splat / point-cloud.

- Comfy.PreviewGaussianSplat targets backend node 'PreviewGaussianSplat'
(.ply / .spz / .splat / .ksplat).
- Comfy.PreviewPointCloud targets backend node 'PreviewPointCloud' (.ply
point clouds).

PLY auto-dispatch, no more user-facing engine choice for 3DGS:

**Please be aware that I have not yet implemented any UI optimizations
on the frontend for world models such as World Labs' Marble, no WSAD
controls, no scale optimization yet**

BE: https://github.com/Comfy-Org/ComfyUI/pull/14194

## Screenshots (if applicable)
ksplat file:
<img width="2714" height="1391" alt="image"
src="https://github.com/user-attachments/assets/9024db9d-20e9-44ea-ab14-500810d2946a"
/>
splat file:
<img width="2938" height="1410" alt="image"
src="https://github.com/user-attachments/assets/de768fa5-9d55-4560-9fb3-b218b96ea0c7"
/>
spz file:
<img width="1729" height="845" alt="image"
src="https://github.com/user-attachments/assets/cc09e568-77c9-45b3-a6cc-8f5d1062f3ec"
/>
ply (splat) file:
<img width="1702" height="843" alt="image"
src="https://github.com/user-attachments/assets/2a51c2ce-046b-4843-9e58-634bc45cbcce"
/>
ply (point cloud) file:
<img width="1701" height="842" alt="image"
src="https://github.com/user-attachments/assets/db75808e-3481-4ecc-8582-e4fec21163fd"
/>
2026-06-01 14:25:06 -04:00
212 changed files with 12652 additions and 2154 deletions

View File

@@ -84,6 +84,8 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/hoisted-apis-on-top": "error",
"typescript/no-misused-spread": "error",
"vitest/consistent-each-for": [
"error",
{

View File

@@ -1,95 +1,60 @@
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
/src/components/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
/src/extensions/core/imageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/imagecrop/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useImageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
/src/extensions/core/imageCompare.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
/src/extensions/core/painter.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3dLazy.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/extensions/core/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3d.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
/src/services/load3dService.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -0,0 +1,140 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates/terms'
const SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
test.describe('Affiliate Terms — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('exposes one anchor per legal section in order', async ({ page }) => {
for (const id of SECTION_IDS) {
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
}
const orderedIds = await page.evaluate(
(ids) => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
return elements
.slice()
.sort((a, b) => {
const relation = a.compareDocumentPosition(b)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
.map((el) => el.id)
},
[...SECTION_IDS]
)
expect(orderedIds).toEqual([...SECTION_IDS])
})
test('renders an effective date footer', async ({ page }) => {
await expect(page.getByText(/Effective Date:/)).toBeVisible()
})
test('skips internal-only sections (competitive analysis, open questions)', async ({
page
}) => {
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
await expect(
page.getByText(/Open questions for legal review/i)
).toHaveCount(0)
})
})
test.describe('Affiliate Terms — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('clicking a desktop TOC link scrolls to the matching section', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await expect(desktopToc).toBeVisible()
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
await link.click()
const target = page.locator('[id="5-prohibited-activities"]')
await expect(target).toBeInViewport()
})
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe('#7-termination')
})
})
test.describe('Affiliate Terms — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows a collapsed accordion TOC by default', async ({ page }) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await expect(accordion).toBeVisible()
await expect(accordion).not.toHaveAttribute('open', '')
})
test('expanding the accordion reveals every section link', async ({
page
}) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await accordion.locator('summary').click()
await expect(accordion).toHaveAttribute('open', '')
for (const id of SECTION_IDS) {
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
}
})
test('headings remain readable at narrow viewports without horizontal overflow', async ({
page
}) => {
const heading = page.getByRole('heading', { name: '1. Program Overview' })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'heading box').not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
})
})

View File

@@ -0,0 +1,200 @@
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 { 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
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
).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 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.describe('Affiliates landing — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('emits FAQPage structured data with one entry per FAQ', async ({
page
}) => {
const faqJsonLd = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
const match = scripts.find((s) =>
(s.textContent ?? '').includes('FAQPage')
)
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
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')
})
test('Apply Now CTA opens the application form in a new tab', async ({
page,
context
}) => {
const popupPromise = context.waitForEvent('page')
await page.getByTestId('affiliate-hero-cta').click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
expect(
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
popupUrl.includes('docs.google.com/forms')
).toBe(true)
await popup.close()
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
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 firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Affiliates landing — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero heading and primary CTA 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)
).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
)
}
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,3 @@
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#211927"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -31,28 +31,4 @@ Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -0,0 +1,42 @@
<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

@@ -0,0 +1,62 @@
<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

@@ -0,0 +1,43 @@
<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

@@ -0,0 +1,84 @@
<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

@@ -0,0 +1,55 @@
<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

@@ -0,0 +1,79 @@
<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

@@ -0,0 +1,20 @@
<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

@@ -0,0 +1,6 @@
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

@@ -0,0 +1,154 @@
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

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,33 @@
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

@@ -9,12 +9,14 @@ import { brandButtonVariants } from './brandButton.variants'
const {
href,
target,
rel,
variant,
size,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
variant?: BrandButtonVariants['variant']
size?: BrandButtonVariants['size']
class?: HTMLAttributes['class']
@@ -26,6 +28,7 @@ const {
:is="href ? 'a' : 'button'"
:href
:target
:rel
:class="cn(brandButtonVariants({ variant, size }), customClass)"
>
<span class="ppformula-text-center">

View File

@@ -67,6 +67,10 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
},
{
label: t('footer.affiliateProgram', locale),
href: routes.affiliates
}
]
}

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { scrollTo } from '../../scripts/smoothScroll'
const {
prefix,
locale = 'en',
tocLabelKey
} = defineProps<{
prefix: string
locale?: Locale
tocLabelKey: TranslationKey
}>()
interface Block {
type: 'paragraph' | 'list'
key: TranslationKey
}
interface LegalSection {
id: string
title: string
blocks: Block[]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildSections(): LegalSection[] {
const labelRegex = new RegExp(`^${escapeRegex(prefix)}\\.([^.]+)\\.label$`)
const sectionIds: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !sectionIds.includes(match[1])) sectionIds.push(match[1])
}
return sectionIds.map((id) => {
const blockRegex = new RegExp(
`^${escapeRegex(prefix)}\\.${escapeRegex(id)}\\.block\\.(\\d+)$`
)
const indices: number[] = []
for (const key of translationKeys) {
const match = key.match(blockRegex)
if (match) indices.push(parseInt(match[1]))
}
indices.sort((a, b) => a - b)
const blocks: Block[] = indices.map((i) => {
const key = `${prefix}.${id}.block.${i}` as TranslationKey
const value = t(key, locale)
return { type: value.includes('\n') ? 'list' : 'paragraph', key }
})
const titleKey = `${prefix}.${id}.title` as TranslationKey
return {
id,
title: hasKey(titleKey)
? t(titleKey, locale)
: t(`${prefix}.${id}.label` as TranslationKey, locale),
blocks
}
})
}
const sections = buildSections()
const tocItems = computed(() =>
sections.map((s) => ({ id: s.id, title: s.title }))
)
const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
const mobileTocOpen = ref(false)
let isScrolling = false
const HEADER_OFFSET = -144
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
best = entry
}
if (best) activeSection.value = best.target.id
},
{ rootMargin: '-20% 0px -60% 0px' }
)
function scrollToSection(id: string) {
activeSection.value = id
isScrolling = true
mobileTocOpen.value = false
const nextHash = `#${id}`
if (window.location.hash !== nextHash) {
history.replaceState(null, '', nextHash)
}
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
})
return
}
isScrolling = false
}
function listItems(key: TranslationKey): string[] {
return t(key, locale).split('\n')
}
</script>
<template>
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-12 lg:pb-32">
<div class="mx-auto max-w-7xl lg:flex lg:gap-16">
<aside class="lg:w-64 lg:shrink-0">
<details
:open="mobileTocOpen"
class="border-transparency-white-t4 mb-8 rounded-2xl border bg-(--site-bg-soft) lg:hidden"
@toggle="
(e) => (mobileTocOpen = (e.target as HTMLDetailsElement).open)
"
>
<summary
class="text-primary-comfy-canvas flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-semibold tracking-wide select-none"
>
<span>{{ t(tocLabelKey, locale) }}</span>
<span
:class="
mobileTocOpen
? 'rotate-180 transition-transform'
: 'transition-transform'
"
aria-hidden="true"
>
</span>
</summary>
<ul class="border-transparency-white-t4 border-t p-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
:class="
cn(
'hover:bg-transparency-white-t4 block rounded-lg px-3 py-2 text-sm transition-colors',
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
)
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</details>
<nav
class="hidden lg:sticky lg:top-32 lg:block"
:aria-label="t(tocLabelKey, locale)"
>
<p
class="text-primary-warm-gray mb-4 text-xs font-semibold tracking-widest uppercase"
>
{{ t(tocLabelKey, locale) }}
</p>
<ul class="space-y-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
class="hover:text-primary-comfy-canvas block text-sm/snug transition-colors"
:class="
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</nav>
</aside>
<article class="flex-1 lg:max-w-3xl">
<section
v-for="section in sections"
:id="section.id"
:ref="sectionRefs.set"
:key="section.id"
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
>
<h2
class="text-primary-comfy-canvas mb-6 text-2xl font-light lg:text-3xl"
>
{{ section.title }}
</h2>
<template v-for="block in section.blocks" :key="block.key">
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed lg:text-base/relaxed"
v-html="t(block.key, locale)"
/>
<ul
v-else
class="mt-4 space-y-2 pl-5 text-sm/relaxed lg:text-base/relaxed"
>
<li
v-for="(item, j) in listItems(block.key)"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<span
class="bg-primary-comfy-yellow mt-2 size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span v-html="item" />
</li>
</ul>
</template>
</section>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, t, translationKeys } from '../../i18n/translations'
const PREFIX = 'affiliate-terms'
const EXPECTED_SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
function deriveAffiliateSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('affiliate terms i18n', () => {
it('exposes the eleven canonical sections in numeric order', () => {
const ids = deriveAffiliateSectionIds()
expect(ids).toEqual([...EXPECTED_SECTION_IDS])
})
it('every section has a label, title, and at least one block', () => {
for (const id of EXPECTED_SECTION_IDS) {
expect(hasKey(`${PREFIX}.${id}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('section titles follow the "N. Section Name" pattern', () => {
for (const id of EXPECTED_SECTION_IDS) {
const title = t(`${PREFIX}.${id}.title` as never)
const numberPrefix = id.split('-')[0]
expect(title).toMatch(new RegExp(`^${numberPrefix}\\. `))
}
})
it('exposes the effective date and page-chrome keys editors will need', () => {
expect(hasKey('affiliate-terms.effective-date')).toBe(true)
expect(hasKey('affiliate-terms.page.title')).toBe(true)
expect(hasKey('affiliate-terms.page.heading')).toBe(true)
expect(hasKey('affiliate-terms.page.tocLabel')).toBe(true)
expect(hasKey('affiliate-terms.page.effectiveDateLabel')).toBe(true)
})
it('does not include any internal-only "Competitive analysis" or "Open questions" keys', () => {
const internalRegex = /(competitive-analysis|open-questions|legal-review)/
const leaks = translationKeys.filter(
(key) => key.startsWith(PREFIX) && internalRegex.test(key)
)
expect(leaks).toEqual([])
})
it('exposes affiliate terms at the canonical /affiliates/terms path regardless of locale', () => {
// Guards against re-introducing /zh-CN/affiliates/terms, which would
// serve an unreviewed translation of legal-reviewed copy. See the
// comment on LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
expect(getRoutes('en').affiliateTerms).toBe('/affiliates/terms')
expect(getRoutes('zh-CN').affiliateTerms).toBe('/affiliates/terms')
})
})

View File

@@ -2,7 +2,6 @@
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
@@ -116,8 +115,6 @@ const plans: PricingPlan[] = [
const standardPlans = plans.filter((p) => !p.isEnterprise)
const enterprisePlan = plans.find((p) => p.isEnterprise)!
const activePlanIndex = ref(0)
</script>
<template>
@@ -134,28 +131,7 @@ const activePlanIndex = ref(0)
</p>
</div>
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"
:class="
cn(
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
activePlanIndex === index
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
)
"
@click="activePlanIndex = index"
>
<span class="ppformula-text-center">
{{ t(plan.labelKey, locale) }}
</span>
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
<div
:class="
cn(
@@ -273,13 +249,9 @@ const activePlanIndex = ref(0)
</PricingTierCard>
</div>
<!-- Mobile: single plan view -->
<div class="lg:hidden">
<div
v-for="(plan, index) in plans"
:key="plan.id"
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
>
<!-- Mobile: stacked plans -->
<div class="flex flex-col gap-8 lg:hidden">
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
<!-- Main info card -->
<div class="bg-transparency-white-t4 rounded-3xl p-6">
<!-- Label + badge -->

View File

@@ -11,14 +11,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-visible lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto mb-12 flex flex-col items-center overflow-hidden px-4 md:flex-row md:overflow-visible md:pt-20 lg:items-center lg:space-x-20"
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-125 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-10 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
<svg
class="block size-full overflow-visible"
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
viewBox="50 50 900 900"
fill="none"
aria-hidden="true"
@@ -378,9 +376,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</div>
<!-- Text -->
<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-[25%] lg:px-20 lg:py-14"
>
<div class="relative z-10 lg:flex-1">
<ProductHeroBadge text="CLOUD" />
<h1
@@ -390,7 +386,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
class="text-primary-comfy-canvas mt-6 max-w-lg text-sm lg:mt-6 lg:text-base"
>
{{ t('cloud.hero.subtitle', locale) }}
</p>

View File

@@ -168,7 +168,7 @@ onUnmounted(() => {
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-550/800 w-4/5 max-w-xs self-center overflow-visible md:max-w-sm lg:pointer-events-none lg:z-1 lg:-mr-12 lg:max-w-md lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"

View File

@@ -12,7 +12,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
class="text-primary-comfy-canvas relative z-10 text-sm font-semibold lg:text-sm lg:font-normal"
>
<span class="whitespace-nowrap">
{{ t('download.cloud.prefix', locale) }}

View File

@@ -14,13 +14,29 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
// Routes that are served only at their canonical path regardless of the
// active locale. Localized variants of these routes intentionally do not
// exist, so getRoutes(<non-en>) must not prefix them — emitting
// /zh-CN/<route> would produce a dead link.
//
// affiliateTerms: legal-reviewed English-only document. See the comment
// header in src/pages/affiliates/terms.astro and the affiliate-terms i18n
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService'
])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
@@ -28,12 +44,13 @@ export function getRoutes(locale: Locale = 'en'): Routes {
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
export const externalLinks = {
affiliateApplicationForm: 'https://forms.gle/RS8L2ttcuGap4Q1v6',
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',

View File

@@ -1775,6 +1775,10 @@ const translations = {
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.affiliateProgram': {
en: 'Affiliate Program',
'zh-CN': 'Affiliate Program'
},
'footer.location': {
en: 'San Francisco, USA',
'zh-CN': '美国旧金山'
@@ -2640,6 +2644,189 @@ const translations = {
'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.'
},
// ── Affiliate Program Terms ───────────────────────────────────────
// Legal-reviewed copy — ENGLISH ONLY. There is no /zh-CN/affiliates/terms
// route, and the `'zh-CN'` values below intentionally duplicate `en`
// verbatim only to satisfy the translations dictionary's required
// Record<Locale, string> shape. Do NOT translate these into Chinese:
// shipping an unreviewed translation as the active terms exposes us to
// liability from the translation diverging from the legal-approved
// English source. If a translated terms page is ever needed, add a
// separate `/affiliates/terms/<locale>` route only after legal signs
// off on that specific translation as the authoritative version.
'affiliate-terms.effective-date': {
en: 'May 16, 2026',
'zh-CN': 'May 16, 2026'
},
'affiliate-terms.1-program-overview.label': {
en: 'PROGRAM',
'zh-CN': 'PROGRAM'
},
'affiliate-terms.1-program-overview.title': {
en: '1. Program Overview',
'zh-CN': '1. Program Overview'
},
'affiliate-terms.1-program-overview.block.0': {
en: 'The <a href="https://comfy.org" class="text-white underline">Comfy.org</a> Affiliate Program ("<strong>Program</strong>") allows approved participants ("<strong>Affiliates</strong>") to earn commissions by referring new paying customers to Comfy Cloud. By participating in this program, you agree to these terms.',
'zh-CN':
'The <a href="https://comfy.org" class="text-white underline">Comfy.org</a> Affiliate Program ("<strong>Program</strong>") allows approved participants ("<strong>Affiliates</strong>") to earn commissions by referring new paying customers to Comfy Cloud. By participating in this program, you agree to these terms.'
},
'affiliate-terms.2-eligible-products.label': {
en: 'PRODUCTS',
'zh-CN': 'PRODUCTS'
},
'affiliate-terms.2-eligible-products.title': {
en: '2. Eligible Products',
'zh-CN': '2. Eligible Products'
},
'affiliate-terms.2-eligible-products.block.0': {
en: 'Commissions are earned on Comfy Cloud paid subscription plans only. The following are excluded from commission eligibility: free tier signups (unless they later convert to paid), one-time credit purchases, enterprise contracts negotiated directly with Comfy sales, and API-only usage billed outside of standard subscription plans.',
'zh-CN':
'Commissions are earned on Comfy Cloud paid subscription plans only. The following are excluded from commission eligibility: free tier signups (unless they later convert to paid), one-time credit purchases, enterprise contracts negotiated directly with Comfy sales, and API-only usage billed outside of standard subscription plans.'
},
'affiliate-terms.3-commission-structure.label': {
en: 'COMMISSION',
'zh-CN': 'COMMISSION'
},
'affiliate-terms.3-commission-structure.title': {
en: '3. Commission Structure',
'zh-CN': '3. Commission Structure'
},
'affiliate-terms.3-commission-structure.block.0': {
en: 'Commission rate: 30% recurring on the net subscription amount received by Comfy.org\nCommission duration: 3 months from the referred customer\u2019s first paid subscription\nCookie/attribution window: 60 days from the referral click\nMinimum payout threshold: $100\nPayout schedule: Monthly, within the first 10 business days of each month after the receipt of applicable payment by Comfy from its referred customer\nPayout method: Via the affiliate tracking platform (Stripe Express or PayPal)\nCommission cessation: To the extent a referred customer\u2019s subscription is canceled, in whole or in part, the affiliate shall correspondingly cease to receive commission payments, even within the 3-month commission window. Refunded or charged-back transactions are not eligible for commission, and any commission previously paid for such transactions will be deducted from future payouts (see Section 4).',
'zh-CN':
'Commission rate: 30% recurring on the net subscription amount received by Comfy.org\nCommission duration: 3 months from the referred customer\u2019s first paid subscription\nCookie/attribution window: 60 days from the referral click\nMinimum payout threshold: $100\nPayout schedule: Monthly, within the first 10 business days of each month after the receipt of applicable payment by Comfy from its referred customer\nPayout method: Via the affiliate tracking platform (Stripe Express or PayPal)\nCommission cessation: To the extent a referred customer\u2019s subscription is canceled, in whole or in part, the affiliate shall correspondingly cease to receive commission payments, even within the 3-month commission window. Refunded or charged-back transactions are not eligible for commission, and any commission previously paid for such transactions will be deducted from future payouts (see Section 4).'
},
'affiliate-terms.4-attribution-rules.label': {
en: 'ATTRIBUTION',
'zh-CN': 'ATTRIBUTION'
},
'affiliate-terms.4-attribution-rules.title': {
en: '4. Attribution Rules',
'zh-CN': '4. Attribution Rules'
},
'affiliate-terms.4-attribution-rules.block.0': {
en: 'Commissions are attributed on a last-click basis within the 60-day cookie window\nIf a referred customer cancels and re-subscribes within 60 days, the original affiliate retains attribution\nIf a referred customer upgrades their plan, commission is calculated on the upgraded amount\nIf a referred customer downgrades their plan, commission adjusts to the new plan amount\nRefunded transactions are not eligible for commission\nAny commission paid on refunded transactions will be deducted from future payouts to you',
'zh-CN':
'Commissions are attributed on a last-click basis within the 60-day cookie window\nIf a referred customer cancels and re-subscribes within 60 days, the original affiliate retains attribution\nIf a referred customer upgrades their plan, commission is calculated on the upgraded amount\nIf a referred customer downgrades their plan, commission adjusts to the new plan amount\nRefunded transactions are not eligible for commission\nAny commission paid on refunded transactions will be deducted from future payouts to you'
},
'affiliate-terms.5-prohibited-activities.label': {
en: 'PROHIBITED',
'zh-CN': 'PROHIBITED'
},
'affiliate-terms.5-prohibited-activities.title': {
en: '5. Prohibited Activities',
'zh-CN': '5. Prohibited Activities'
},
'affiliate-terms.5-prohibited-activities.block.0': {
en: 'Affiliates must NOT:',
'zh-CN': 'Affiliates must NOT:'
},
'affiliate-terms.5-prohibited-activities.block.1': {
en: '<strong>Self-refer</strong>: Use your own affiliate link to purchase or receive discounts on your own account\n<strong>Bid on branded keywords</strong>: Run paid search campaigns (Google Ads, Bing Ads, etc.) targeting "ComfyUI," "Comfy.org," "Comfy Cloud," or any misspellings or variations thereof\n<strong>Misrepresent</strong>: Impersonate Comfy.org, claim to be an employee, or create assets that could be confused with official Comfy.org materials\n<strong>Spam</strong>: Send unsolicited bulk emails, messages, or engage in any form of spam promotion\n<strong>Cookie stuff</strong>: Use hidden iframes, pop-unders, or any technical means to set cookies without genuine user intent\n<strong>Incentivize clicks</strong>: Offer monetary rewards, points, or other incentives solely for clicking your affiliate link (content recommendations are fine)\n<strong>Use misleading claims</strong>: Make false or exaggerated claims about Comfy.org products, pricing, or features\n<strong>Promote on prohibited content</strong>: Place affiliate links on sites containing illegal content, hate speech, or adult content\n<strong>Contrary to laws</strong>: Place affiliate links in any market that is prohibited as a region under the laws of the United States of America.',
'zh-CN':
'<strong>Self-refer</strong>: Use your own affiliate link to purchase or receive discounts on your own account\n<strong>Bid on branded keywords</strong>: Run paid search campaigns (Google Ads, Bing Ads, etc.) targeting "ComfyUI," "Comfy.org," "Comfy Cloud," or any misspellings or variations thereof\n<strong>Misrepresent</strong>: Impersonate Comfy.org, claim to be an employee, or create assets that could be confused with official Comfy.org materials\n<strong>Spam</strong>: Send unsolicited bulk emails, messages, or engage in any form of spam promotion\n<strong>Cookie stuff</strong>: Use hidden iframes, pop-unders, or any technical means to set cookies without genuine user intent\n<strong>Incentivize clicks</strong>: Offer monetary rewards, points, or other incentives solely for clicking your affiliate link (content recommendations are fine)\n<strong>Use misleading claims</strong>: Make false or exaggerated claims about Comfy.org products, pricing, or features\n<strong>Promote on prohibited content</strong>: Place affiliate links on sites containing illegal content, hate speech, or adult content\n<strong>Contrary to laws</strong>: Place affiliate links in any market that is prohibited as a region under the laws of the United States of America.'
},
'affiliate-terms.6-content-guidelines.label': {
en: 'CONTENT & IP',
'zh-CN': 'CONTENT & IP'
},
'affiliate-terms.6-content-guidelines.title': {
en: '6. Content Guidelines and Intellectual Property Rights',
'zh-CN': '6. Content Guidelines and Intellectual Property Rights'
},
'affiliate-terms.6-content-guidelines.block.0': {
en: 'Affiliates must clearly disclose the affiliate relationship in accordance with FTC guidelines (US) and equivalent regulations in their jurisdiction\nRecommended disclosure: "This page contains affiliate links. I may earn a commission if you sign up through my link."\nAffiliates may use Comfy.org logos and brand assets only as provided in the official affiliate asset kit, and may not modify them\nComfy.org retains all rights, including in any of its intellectual property apart from the limited use rights granted herein',
'zh-CN':
'Affiliates must clearly disclose the affiliate relationship in accordance with FTC guidelines (US) and equivalent regulations in their jurisdiction\nRecommended disclosure: "This page contains affiliate links. I may earn a commission if you sign up through my link."\nAffiliates may use Comfy.org logos and brand assets only as provided in the official affiliate asset kit, and may not modify them\nComfy.org retains all rights, including in any of its intellectual property apart from the limited use rights granted herein'
},
'affiliate-terms.7-termination.label': {
en: 'TERMINATION',
'zh-CN': 'TERMINATION'
},
'affiliate-terms.7-termination.title': {
en: '7. Termination',
'zh-CN': '7. Termination'
},
'affiliate-terms.7-termination.block.0': {
en: 'Either party may terminate the affiliate relationship at any time with 14 days\u2019 prior written notice\nComfy.org reserves the right to immediately terminate and withhold commissions if an affiliate violates any of the prohibited activities listed above\nUpon termination, any unpaid commissions above the minimum threshold will be paid in the next regular payout cycle\nCommissions on referred customers will cease at the time of termination, even if within the 3-month commission window',
'zh-CN':
'Either party may terminate the affiliate relationship at any time with 14 days\u2019 prior written notice\nComfy.org reserves the right to immediately terminate and withhold commissions if an affiliate violates any of the prohibited activities listed above\nUpon termination, any unpaid commissions above the minimum threshold will be paid in the next regular payout cycle\nCommissions on referred customers will cease at the time of termination, even if within the 3-month commission window'
},
'affiliate-terms.8-program-modifications.label': {
en: 'MODIFICATIONS',
'zh-CN': 'MODIFICATIONS'
},
'affiliate-terms.8-program-modifications.title': {
en: '8. Program Modifications',
'zh-CN': '8. Program Modifications'
},
'affiliate-terms.8-program-modifications.block.0': {
en: 'Comfy.org reserves the right to modify these terms, commission rates, or program structure with 30 days notice to active affiliates\nContinued participation after notice constitutes acceptance of modified terms',
'zh-CN':
'Comfy.org reserves the right to modify these terms, commission rates, or program structure with 30 days notice to active affiliates\nContinued participation after notice constitutes acceptance of modified terms'
},
'affiliate-terms.9-indemnification.label': {
en: 'LIABILITY',
'zh-CN': 'LIABILITY'
},
'affiliate-terms.9-indemnification.title': {
en: '9. Indemnification and Limitation of Liability',
'zh-CN': '9. Indemnification and Limitation of Liability'
},
'affiliate-terms.9-indemnification.block.0': {
en: 'You will indemnify Comfy.org from any third party claim arising out of your breach of these terms.\nComfy.org\u2019s liability to any affiliate shall not (i) exceed the total commissions paid to that affiliate in the preceding 12 months, and (ii) include any indirect, consequential, punitive or any other type of special damages.\nComfy.org is not responsible for tracking failures caused by user browser settings, ad blockers, or VPNs, though we employ server-side tracking to minimize these issues',
'zh-CN':
'You will indemnify Comfy.org from any third party claim arising out of your breach of these terms.\nComfy.org\u2019s liability to any affiliate shall not (i) exceed the total commissions paid to that affiliate in the preceding 12 months, and (ii) include any indirect, consequential, punitive or any other type of special damages.\nComfy.org is not responsible for tracking failures caused by user browser settings, ad blockers, or VPNs, though we employ server-side tracking to minimize these issues'
},
'affiliate-terms.10-governing-law.label': {
en: 'GOVERNING LAW',
'zh-CN': 'GOVERNING LAW'
},
'affiliate-terms.10-governing-law.title': {
en: '10. Governing Law',
'zh-CN': '10. Governing Law'
},
'affiliate-terms.10-governing-law.block.0': {
en: 'These terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to conflict of law principles. All disputes arising under this Agreement shall be resolved exclusively in the state or federal courts in the State of Delaware.',
'zh-CN':
'These terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to conflict of law principles. All disputes arising under this Agreement shall be resolved exclusively in the state or federal courts in the State of Delaware.'
},
'affiliate-terms.11-miscellaneous.label': {
en: 'MISCELLANEOUS',
'zh-CN': 'MISCELLANEOUS'
},
'affiliate-terms.11-miscellaneous.title': {
en: '11. Miscellaneous',
'zh-CN': '11. Miscellaneous'
},
'affiliate-terms.11-miscellaneous.block.0': {
en: '<strong>(a) Entire Agreement.</strong> These terms constitutes the sole and entire agreement (including the attached schedules and exhibits) of the Parties with respect to the subject matter of these terms, and supersedes all prior and contemporaneous understandings, agreements, representations, and warranties, both written and oral, with respect to the subject matter. <strong>(b) Relationship of Parties.</strong> Each party is an independent contractor with regard to these terms. Nothing contained in These terms shall be construed as creating any agency, partnership, joint venture, or other form of joint enterprise, employment, or fiduciary relationship between the Parties. Neither party, by virtue of these terms, will have any right, power, or authority to act or create an obligation, express or implied, on behalf of the other party. <strong>(c) Assignment.</strong> Neither party shall assign any of its rights or delegate any of its obligations hereunder without the prior written consent of the other party, which consent shall not be unreasonably withheld, conditioned or delayed. <strong>(d) Severability.</strong> If any term or provision of these terms is invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability shall not affect any other term or provision of these terms or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon a determination that any term or provision is invalid, illegal or unenforceable, the Parties hereto shall negotiate in good faith to modify these terms to effect the original intent of the Parties as closely as possible in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. <strong>(e) Waiver.</strong> No waiver by either party of any of the provisions hereof shall be effective unless explicitly set forth in writing and signed by the party so waiving. <strong>(f) Notice.</strong> Each party shall deliver all notices, requests, consents, claims, demands, waivers, and other communications under these terms in writing to the email utilized for the primary contact for the other party. <strong>(g) Cumulative Remedies.</strong> All rights and remedies provided in these terms are cumulative and not exclusive, and the exercise by a party of any right or remedy does not preclude the exercise of any other rights or remedies that may now or subsequently be available at Law, in equity, by statute, in any other agreement between the Parties or otherwise. <strong>(h) No Third-Party Beneficiaries.</strong> These terms benefits solely the Parties to these terms and their respective permitted successors and assigns and nothing in these terms, express or implied, confers on any other person or entity any legal or equitable right, benefit, or remedy of any nature whatsoever under or by reason of these terms.',
'zh-CN':
'<strong>(a) Entire Agreement.</strong> These terms constitutes the sole and entire agreement (including the attached schedules and exhibits) of the Parties with respect to the subject matter of these terms, and supersedes all prior and contemporaneous understandings, agreements, representations, and warranties, both written and oral, with respect to the subject matter. <strong>(b) Relationship of Parties.</strong> Each party is an independent contractor with regard to these terms. Nothing contained in These terms shall be construed as creating any agency, partnership, joint venture, or other form of joint enterprise, employment, or fiduciary relationship between the Parties. Neither party, by virtue of these terms, will have any right, power, or authority to act or create an obligation, express or implied, on behalf of the other party. <strong>(c) Assignment.</strong> Neither party shall assign any of its rights or delegate any of its obligations hereunder without the prior written consent of the other party, which consent shall not be unreasonably withheld, conditioned or delayed. <strong>(d) Severability.</strong> If any term or provision of these terms is invalid, illegal, or unenforceable in any jurisdiction, such invalidity, illegality, or unenforceability shall not affect any other term or provision of these terms or invalidate or render unenforceable such term or provision in any other jurisdiction. Upon a determination that any term or provision is invalid, illegal or unenforceable, the Parties hereto shall negotiate in good faith to modify these terms to effect the original intent of the Parties as closely as possible in order that the transactions contemplated hereby be consummated as originally contemplated to the greatest extent possible. <strong>(e) Waiver.</strong> No waiver by either party of any of the provisions hereof shall be effective unless explicitly set forth in writing and signed by the party so waiving. <strong>(f) Notice.</strong> Each party shall deliver all notices, requests, consents, claims, demands, waivers, and other communications under these terms in writing to the email utilized for the primary contact for the other party. <strong>(g) Cumulative Remedies.</strong> All rights and remedies provided in these terms are cumulative and not exclusive, and the exercise by a party of any right or remedy does not preclude the exercise of any other rights or remedies that may now or subsequently be available at Law, in equity, by statute, in any other agreement between the Parties or otherwise. <strong>(h) No Third-Party Beneficiaries.</strong> These terms benefits solely the Parties to these terms and their respective permitted successors and assigns and nothing in these terms, express or implied, confers on any other person or entity any legal or equitable right, benefit, or remedy of any nature whatsoever under or by reason of these terms.'
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms — Comfy',
'zh-CN': 'Affiliate Terms — Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
'zh-CN': 'Comfy.org Affiliate Program Terms and Conditions.'
},
'affiliate-terms.page.heading': {
en: 'Affiliate Terms',
'zh-CN': 'Affiliate Terms'
},
'affiliate-terms.page.tocLabel': {
en: 'On this page',
'zh-CN': '本页内容'
},
'affiliate-terms.page.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': '生效日期'
},
// Customers page
'customers.hero.label': {
en: 'CUSTOMER STORIES',
@@ -4235,6 +4422,315 @@ const translations = {
'payment.failed.secondaryCta': {
en: 'READ SUBSCRIPTION DOCS',
'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'
},
'affiliate-landing.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)'
},
// 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': {
en: 'Ready to start earning?',
'zh-CN': 'Ready to start earning?'
},
'affiliate-landing.footerCta.termsLink': {
en: 'Read the affiliate program terms',
'zh-CN': 'Read the affiliate program terms'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -0,0 +1,65 @@
---
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 { t } from '../../i18n/translations'
const locale: Locale =
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
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)
}
}
})
}
---
<BaseLayout
title={t('affiliate-landing.page.title', locale)}
description={t('affiliate-landing.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(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 />
</BaseLayout>

View File

@@ -0,0 +1,30 @@
---
// Affiliate Program Terms — English only, by design.
// Legal-reviewed copy must not be served under a localized route until
// legal explicitly approves a translation; rendering an unreviewed
// translation as the active terms exposes us to liability from the
// translation diverging from the approved English source. See the
// matching comment in src/i18n/translations.ts for the i18n block.
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/legal/HeroSection.vue'
import LegalContentSection from '../../components/legal/LegalContentSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('affiliate-terms.page.title')}
description={t('affiliate-terms.page.description')}
>
<HeroSection title={t('affiliate-terms.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('affiliate-terms.page.effectiveDateLabel')}: {
t('affiliate-terms.effective-date')
}
</p>
<LegalContentSection
prefix="affiliate-terms"
locale="en"
tocLabelKey="affiliate-terms.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,5 +1,7 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -18,7 +20,9 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
})
initialized = true
} catch (error) {

View File

@@ -1,7 +1,6 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`

View File

@@ -27,6 +27,10 @@ export class ContextMenu {
await this.waitForHidden()
}
menuItem(name: string): Locator {
return this.anyMenu.getByRole('menuitem', { name, exact: true })
}
/**
* Click a litegraph menu entry. Selects the most recently opened matching
* entry so nested submenu items can be reached without being shadowed by

View File

@@ -137,7 +137,8 @@ export const TestIds = {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -4,6 +4,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ChangeTrackerDebugState = {
changeCount: number
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
]
})
})
test(
'Tracks convert to subgraph as undo step',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
await input.fill('40')
await node.title.click()
await comfyPage.page
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
.click()
await expect(input).toBeHidden()
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('40')
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('512')
}
)
})

View File

@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -174,12 +174,33 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
await openContextMenu(comfyPage, nodeTitle)
await expect(bypassItem).toHaveCount(1)
await expect(removeBypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect(nodeRef).toBeBypassed()
await openContextMenu(comfyPage, nodeTitle)
await expect(removeBypassItem).toHaveCount(1)
await expect(bypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect(nodeRef).not.toBeBypassed()
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
@@ -451,7 +472,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -460,7 +481,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)

View File

@@ -239,19 +239,20 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
Companion architecture documents that expand on the design in this ADR:
| Document | Description |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
| Document | Description |
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
## Notes

View File

@@ -14,8 +14,8 @@ reads could collapse back to the interior source widget, while host
flowchart TD
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
workflow --> hostValues[host widgets_values]
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
promotionStore --> sourceWidget[Interior source widget]
proxyWidgets --> legacyRuntime[Legacy promotion runtime (removed)]
legacyRuntime --> sourceWidget[Interior source widget]
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
sourceWidget --> hostWidget
hostValues --> hostWidget
@@ -27,7 +27,7 @@ flowchart TD
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
class proxyWidgets,promotionStore legacy
class proxyWidgets,legacyRuntime legacy
class sourceWidget,hostValues ambiguous
class linkedInput,hostWidget canonical
```

View File

@@ -0,0 +1,388 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
analysis that shipped slice 1 of
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
which structural patterns our `src/world/` substrate adopts, which it
deliberately departs from, and where the trade-offs are load-bearing rather
than incidental. Thyseus is called out specifically because it is the most
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
closest external analog to the command layer ADR 0003 / ADR 0008 are
converging on, so it gets dedicated treatment in §2.5 and §3.5._
The in-code anchors for the load-bearing constraints discussed below are the
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
contract) — see §3 below.
---
## 1. Survey Comparison
Six libraries were sampled for structural patterns: where component
definitions live relative to the substrate, how components are declared,
how entities are identified, and roughly how large the substrate's public
surface is. Sources: the linked READMEs and docs.
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
| -------------------------------------------------- | ------------------------------------ | ---------------------------------------------- | -------------------- | ---------------------------------------------------------------------: |
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
| [Thyseus](https://github.com/JaimeGensler/thyseus) | Colocated with the consumer | plain ES6 `class` (instances stored as values) | numeric (via handle) | ~25 (`World`/`Schedule`/`Query`/`Commands`/filters/`Resource`/`Event`) |
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
Two structural patterns are unanimous across the surveyed libraries:
1. **Component definitions live with the code that owns the data**, not
inside the substrate package. Whether by explicit recommendation
(Bevy plugins, koota's colocation guidance, Thyseus's
`import { Position, Velocity } from './components'` convention) or by
default (bitECS, miniplex), no surveyed substrate ships pre-defined
component types.
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
~15, miniplex at ~5. ECSY and Thyseus are the outliers: ECSY exposes a
wider class hierarchy, and Thyseus exposes a broader Bevy-shaped
surface (Commands, Schedules, Resources, Events, filter combinators)
because it commits to a full system-execution runtime, not just
storage.
Our slice-1 end state — five source files under
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
this band.
---
## 2. Patterns We Adopt
### 2.1 Substrate is deep; components live in domain code
The mainstream convention is that the ECS substrate exposes only the
machinery — entities, component keys, a World — and component definitions
live next to the system, store, or feature module that owns the data.
This is the Bevy / miniplex / koota convention by design and the bitECS /
ECSY convention by default.
Our substrate follows the same shape: `src/world/` contains entity-ID
brands, the `ComponentKey` definition primitive, and the `World`
interface, but no domain-specific component types. Slice 1 places
`WidgetValueComponent` and `WidgetContainerComponent` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
module that already owns widget value state.
This keeps the substrate / domain seam crisp: the World knows how to store
and look up arbitrary components keyed by entity ID; the domain layer
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
guidance to group code by bounded context. Future components follow the
same rule — `PositionComponent`, when it lands, will live with the layout
domain rather than inside the substrate.
### 2.2 Small public API
The substrate exports ~14 names — comparable to bitECS (~12) and koota
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
target: every exported name is a contract a contributor must understand
before extending the World, and every export is a potential migration
cost when the substrate evolves.
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
split keeps each module single-purpose. `Brand<T,Tag>` is 5
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
carries a two-parameter phantom that enables cross-kind compile-time
checking. `asGraphId` is a single named boundary cast. The two explicit
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
into a parameterized helper because slice 2/3/4 will add factories with
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
`slotEntityId`); the explicit-factory pattern scales linearly with new
entity kinds without growing the helper's signature.
### 2.3 Reactive bridging via existing storage proxy
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
a consumer wants reactive notifications. koota's React layer
(`useTrait(entity, ComponentKey)`) is the closest analog to what
`useUpstreamValue` and future composables want.
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
a plain `computed(() => world.getComponent(id, key))` already provides
fine-grained per-`(entity, component)` tracking — no separate event bus
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
the ECS storage are the same mechanism, so reactivity falls out of the
storage choice rather than being layered on top.
### 2.4 Brand-typed entity IDs
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
`number`, miniplex uses plain object references, koota uses a numeric
`.id()`, and Thyseus hands back a numeric handle wrapped in `Commands`
APIs. Our `Brand<T, Tag>` over each entity kind enables the type-level
cross-kind isolation assertion in
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
entity kinds at compile time.
This is a deliberate departure rather than an accident. It earns its keep
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
component-key declarations would accept any numeric ID and silently allow
cross-kind misuse.
### 2.5 Commands pattern (Thyseus / Bevy) — direction we are converging on
Thyseus mutates the World exclusively through a `Commands` system
parameter:
```ts
export function spawnEntities(commands: Commands) {
commands.spawn().add(new Position()).add(new Velocity(1, 2))
}
```
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
deferred mutations against a command buffer; the World applies them at
defined sync points in the schedule. This is the same shape Bevy uses
and is the closest direct external analog to the mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
[World API and Command Layer](./ecs-world-command-api.md) describe for
this codebase.
We deliberately match the **shape** of this pattern: external callers
submit commands; only the executor calls the World's imperative
`setComponent` / `deleteEntity`. ADR 0008 §"Relationship to ADR 0003"
spells this out, and the parallel with Thyseus is intentional — when we
extend slice 1 with a command executor, the public seam will look much
more like Thyseus's `Commands` than like koota's `entity.set(...)` or
bitECS's `addComponent(world, ...)`.
What we deliberately do **not** copy from Thyseus's commands surface,
yet:
- **Deferred buffering with schedule sync points.** Thyseus batches
commands and flushes them at well-defined frame phases for archetype
efficiency. Our command executor stays synchronous in slice 1 because
Vue reactivity wants writes to be observable in the same microtask,
and we have no archetype churn cost to amortize.
- **Auto-injected `Commands` parameter.** Thyseus's runtime inspects
system signatures and injects `Commands`, `Query<...>`, `Res<...>`,
etc. We do not have a system-runner yet (see §3.5), so commands today
are called through a plain executor module rather than constructor
injection.
The point of calling Thyseus out separately is that when ADR 0008 lands
its command executor slice, "what does this look like in Thyseus?" is a
load-bearing comparison point — not a curiosity. Diverging from the
Bevy/Thyseus shape there should require an explicit justification, not
silent drift.
---
## 3. Patterns We Explicitly Do NOT Adopt
Each of the following is a real industry idiom we considered and rejected
on load-bearing grounds. None of these are pure performance trade-offs.
### 3.1 Replace-on-write usage idioms
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
**replace** component values with new objects on each write. Adopting
either would break
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
shared reactive identity — the contract that lets DOM widget overrides,
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
and the inner `reactive(Map)` keeps a stable cached proxy per
entity-component pair: every `getComponent` returns the same proxy,
regardless of how many writes intervene. `widgetValueStore.registerWidget`
returns that proxy (not the caller's input ref), so `BaseWidget._state`
and every other reader observe the same object. Replace-on-write idioms
would swap the cached proxy on each write and break that stability —
the reactive-identity test in
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
locks in the contract.
### 3.2 SoA / archetype storage
bitECS, koota, miniplex, and Thyseus use sparse-set / archetype storage
internally for cache locality — Thyseus is explicitly archetypal and
sells "lean memory use and cache-friendly iteration" as a headline
feature. Our `reactive(Map<EntityId, unknown>)` is closer to ECSY's AoS
— slower iteration but **integrates natively with Vue's tracking**.
The surface trade-off is performance; the deeper trade-off is identity.
SoA storage spreads each component's fields across parallel typed arrays,
so the per-entity "row object" is reconstructed on read. **A future
migration to SoA would lose the proxy on the row object** — and with it
the shared-reactive-identity contract that `BaseWidget._state` and the
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
just a perf optimization decision.
The contract is pinned in the doc-comment at the top of
[src/world/world.ts](../../src/world/world.ts) — copied here for
proximity:
```ts
/**
* `setComponent` stores values by reference (no clone). The inner
* `reactive(Map)` produces a single cached Vue proxy per entity-component
* pair: every `getComponent` call returns the same proxy, and mutations
* through it propagate to all readers. Note that the proxy is NOT `===`
* to the raw object passed to `setComponent` — read through `getComponent`
* (or a `registerWidget`-style helper that does so internally) and treat
* that proxy as canonical.
*
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
* miniplex's `world.add(entity)`) would swap the cached proxy on each
* write and break the contract; revisiting either consumer is required
* before changing storage semantics.
*/
```
### 3.3 Auto-generated opaque entity IDs
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
external structure. miniplex uses plain object references with the same
property.
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
widget viewed at different subgraph depths shares identity with itself.
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
the same widget at depth 0 and depth 2 would receive different IDs and
diverge.
The contract is pinned in the doc-comment at the top of
[src/world/entityIds.ts](../../src/world/entityIds.ts):
```ts
/**
* Entity IDs are deterministic, content-addressed, and string-prefix
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
*
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
* consumers consistently pass `rootGraph.id` so widgets viewed at
* different subgraph depths share identity. Migrating to numeric IDs
* would break cross-subgraph value sharing. See ADR 0008 and
* widgetValueStore for the canonical keying contract.
*/
```
### 3.4 Substrate-side parent/child relations
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
ships first-class relations. These are useful when many subsystems need
hierarchical traversal at storage-near speeds.
We treat hierarchical traversal as a domain-layer concern instead. The
only structural relation slice 1 needs is `node → widgets` forward
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
and surfaced through `getNodeWidgets()` on the
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
`widget → node` lookup is not modeled in the World at all today —
existing call sites already hold a widget object and read `widget.node`
directly via the `BaseWidget` back-reference, so no substrate-side
parent component earns its keep yet. We may revisit this if multiple
slices need a shared traversal API; until then, keeping hierarchy
domain-local preserves the substrate's "no domain knowledge" property.
### 3.5 Thyseus-style system runner, schedules, and worker threads
Thyseus ships a full execution runtime alongside its storage:
- **System functions as units of work**, written as plain functions
whose parameters (`Commands`, `Query<[Position, Velocity]>`,
`Res<Time>`, `Maybe<Velocity>`, `With<Active>`, `Without<Frozen>`)
describe the data they read and write.
- **Schedules** (`class SetupSchedule extends Schedule {}`,
`world.runSchedule(SetupSchedule)`) name groups of systems and control
ordering / frequency, including fixed-update patterns.
- **Boilerplate-free worker threads** for running disjoint systems in
parallel without `eval()`.
- **Builder `World`** assembled imperatively
(`new World().addSystems(SetupSchedule, spawnEntities).prepare()`).
We deliberately do not adopt any of this in slice 1. The reasons:
1. **Vue already owns scheduling.** Reactivity-driven recomputation,
`watch`, and component render passes are how work runs in this
codebase. Inserting a parallel system scheduler would mean every
piece of work has two possible execution contexts, and consumers
would have to know which one applies. ADR 0008's planned executor is
a thin command-application layer, not a fixed-step ECS schedule.
2. **No parallelism budget to spend.** Worker-thread parallelism pays
off when systems are CPU-bound and clearly data-disjoint. ComfyUI
frontend's hot paths are render and DOM-bound; the cost of marshaling
state across threads would dwarf any gain at our entity counts.
3. **Constructor-style parameter injection has a real DX cost.**
Thyseus's `Query<[Position, Velocity]>` injection requires the
runtime to introspect and resolve types at registration time. That
couples every system to the runner. The plain-function +
`world.getComponent` shape we use today stays trivially testable
without a `World` fixture.
Revisitable if (a) we end up running solver-style passes that are
clearly CPU-bound and disjoint, or (b) the command executor grows enough
phase ordering that an explicit schedule abstraction earns its keep over
ad-hoc call sites. Until then, "Thyseus has a scheduler so we should
too" is not a sufficient argument — the slice-1 substrate intentionally
stops at storage + identity.
---
## 4. When to Revisit
The choices in §3 are deliberate but not eternal. Each has a revisit
threshold.
**SoA / archetype storage.** The break-even point against `reactive(Map)`
iteration is roughly **>10k entities per component** in steady-state hot
paths. ComfyUI's projected widget count through slice 4 stays well under
that. The watch signal is whether a render-loop or solver-loop pass
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
or any successor query — not just micro-benchmarks of `Map.get`.
If we cross that threshold, the migration is non-trivial: SoA loses the
proxy on the row object (see §3.2), so a SoA World must either
reconstruct proxies on read (defeating the perf gain) or move
shared-identity reads back to a domain-side cache. ADR 0008's
"Render-Loop Performance Implications and Mitigations" section already
enumerates the planned mitigations (frame-stable query caches, archetype
buckets, profiling-gated storage upgrades behind the World API).
**Replace-on-write idioms.** Revisitable only if the 40+ extension
ecosystem moves off `BaseWidget._state` shared identity entirely — a
separate, larger slice with explicit cost analysis (re-entry, DOM widget
options.getValue overrides, `linkedWidgets` fan-out,
`useProcessedWidgets` memoization invalidation), out of scope for the
current ADR 0008 implementation.
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
contract is dropped. Today widget value sharing across subgraph depths
depends on it; slice 2 may extend the same contract to `nodeEntityId`
for spatial reads. Until the product requirement changes, opaque IDs
would be a regression.
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
need parent traversal. At one consumer it stays domain-local.
**Thyseus-style system runner / schedule / worker threads.** Revisitable
only when the command executor grows multiple explicit phases that have
to be ordered against each other, or when a profiled CPU-bound, clearly
data-disjoint pass shows worker-thread parallelism would pay for the
marshaling cost. Until both of those conditions land in a real ticket,
keep the substrate at storage + identity and let Vue own scheduling.
---
## 5. Cross-References
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
for the full target taxonomy and migration strategy.
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
end-state shape.
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
independent verification of the architecture documents.

View File

@@ -212,7 +212,6 @@ graph LR
B3["computedHeight, margin"]
B4["drawWidget(), onClick()"]
B5["useWidgetValueStore()"]
B6["usePromotionStore()"]
end
subgraph After["WidgetEntityId + Components"]
@@ -367,7 +366,6 @@ graph TD
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
Canvas -->|"node.graph.remove(node)"| Graph
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
Graph -->|"useLayoutMutations()"| Store3
LLink["LLink"] -->|"useLayoutMutations()"| Store3

View File

@@ -59,12 +59,11 @@ This means the render pass is not idempotent — drawing a node changes its stat
### Store Dependencies in Domain Objects
`BaseWidget` (line 20-22) imports two Pinia stores at the module level:
`BaseWidget` imports a Pinia store at the module level:
- `usePromotionStore` — queried on every `getOutlineColor()` call
- `useWidgetValueStore` — widget state delegation via `setNodeId()`
Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
Similarly, `LGraph` imports `useLayoutMutations` and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
### Serialization Interleaved with Container Logic
@@ -171,7 +170,7 @@ Domain objects call Pinia composables at the module level or in methods, creatin
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts` — same pattern at module scope
- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore`
- `BaseWidget.ts` — imports `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
@@ -192,7 +191,6 @@ The render pass is not pure — it mutates state as a side effect:
| ----------------------------------- | ------------------------------------------------------------------- |
| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays |
| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes |
| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame |
| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance |
This means:

View File

@@ -303,22 +303,29 @@ must choose before Phase 3 of the migration.
### Current mechanism
The current system has three layers:
> **Historical note:** the legacy three-layer mechanism described below
> (PromotionStore, PromotedWidgetViewManager, PromotedWidgetView) has been
> removed by [ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md).
> Promoted value widgets are now standard linked `SubgraphInput` widgets.
> This section is retained for archival context.
1. **PromotionStore** (`src/stores/promotionStore.ts`): A ref-counted Pinia
store mapping `graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracks
which interior widgets are promoted and provides O(1) `isPromotedByAny()`
queries.
The legacy system had three layers:
2. **PromotedWidgetViewManager**: A reconciliation layer that maintains stable
`PromotedWidgetView` proxy widget objects, diffing against the store on each
update — a pattern analogous to virtual DOM reconciliation.
1. **PromotionStore** (removed; formerly `src/stores/promotionStore.ts`): A
ref-counted Pinia store mapping
`graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracked which interior
widgets were promoted and provided O(1) `isPromotedByAny()` queries.
3. **PromotedWidgetView**: A proxy widget on the SubgraphNode that mirrors the
interior widget's type, value, and options. Reads and writes delegate to the
original widget's entry in `WidgetValueStore`.
2. **PromotedWidgetViewManager** (removed): A reconciliation layer that
maintained stable `PromotedWidgetView` proxy widget objects, diffing against
the store on each update — a pattern analogous to virtual DOM reconciliation.
Serialized as `properties.proxyWidgets` on the SubgraphNode.
3. **PromotedWidgetView** (removed): A proxy widget on the SubgraphNode that
mirrored the interior widget's type, value, and options. Reads and writes
delegated to the original widget's entry in `WidgetValueStore`.
Serialized as `properties.proxyWidgets` on the SubgraphNode (now consumed only
during legacy load repair).
### Candidate A: Connections-only

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"

View File

@@ -2,6 +2,7 @@ import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
treatTagHintsAsErrors: true,
workspaces: {
'.': {
entry: [

View File

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

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/tools": "catalog:",
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "catalog:"

View File

@@ -0,0 +1,55 @@
import {
cleanupSVG,
importDirectorySync,
isEmptyColor,
parseColors,
runSVGO
} from '@iconify/tools'
import { resolve } from 'node:path'
export const COMFY_ICON_PREFIX = 'comfy'
const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
let cached
/**
* Load the comfy icon folder as a normalized Iconify icon set.
*
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
* runs internally so monotone hardcoded colors become `currentColor` and
* outer-svg attributes like `fill="none"` survive the body extraction.
*/
export function loadComfyIconSet() {
if (cached) return cached
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
iconSet.forEachSync((name, type) => {
if (type !== 'icon') return
const svg = iconSet.toSVG(name)
if (!svg) {
iconSet.remove(name)
return
}
try {
cleanupSVG(svg)
const palette = parseColors(svg)
const colors = palette.colors.filter(
(color) => typeof color === 'string' || !isEmptyColor(color)
)
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
if (totalColors < 2) {
parseColors(svg, {
defaultColor: 'currentColor',
callback: (_attr, colorStr, color) =>
!color || isEmptyColor(color) ? colorStr : 'currentColor'
})
}
runSVGO(svg)
iconSet.fromSVG(name, svg)
} catch {
iconSet.remove(name)
}
})
cached = iconSet.export()
return cached
}

View File

@@ -0,0 +1,23 @@
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
const SCALE = 1.2
const options = {
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
scale: SCALE
}
export default plugin(({ matchComponents }) => {
matchComponents({
icon: (icon) => {
try {
return getDynamicCSSRules(icon, options)
} catch {
return {}
}
}
})
})

View File

@@ -1,11 +1,13 @@
import plugin from 'tailwindcss/plugin'
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
import { matchIconName } from '@iconify/utils/lib/icon/name'
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
/**
* Tailwind 4 plugin that provides lucide icon variants with configurable
* stroke-width via class prefix.
* Tailwind 4 plugin that provides icon variants with configurable
* stroke-width via class prefix. Supports lucide and comfy icon sets.
*
* Usage in CSS:
* @plugin "./lucideStrokePlugin.js";
@@ -13,25 +15,40 @@ import { matchIconName } from '@iconify/utils/lib/icon/name'
* Usage in templates:
* <i class="icon-s1-[lucide--settings]" /> <!-- stroke-width: 1 -->
* <i class="icon-s1.5-[lucide--settings]" /> <!-- stroke-width: 1.5 -->
* <i class="icon-s2.5-[lucide--settings]" /> <!-- stroke-width: 2.5 -->
* <i class="icon-s2.5-[comfy--workflow]" /> <!-- stroke-width: 2.5 -->
*
* The default class remains stroke-width: 2.
* The plain `icon-[...]` class keeps each icon's native stroke-width.
*/
const STROKE_WIDTHS = ['1', '1.3', '1.5', '2', '2.5']
const LUCIDE_PREFIX = 'lucide'
const SUPPORTED_PREFIXES = new Set([LUCIDE_PREFIX, COMFY_ICON_PREFIX])
const SCALE = 1.2
const STROKE_WIDTH_ATTR_RE = /stroke-width="[^"]*"/g
class InvalidIconProbeError extends Error {}
function resolveIconSet(prefix) {
if (prefix === COMFY_ICON_PREFIX) return loadComfyIconSet()
return loadIconSet(prefix)
}
function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
const nameParts = icon.split(/--|:/)
if (nameParts.length !== 2) {
throw new Error(`Invalid icon name: "${icon}"`)
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
}
const [prefix, name] = nameParts
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
throw new Error(`Invalid icon name: "${icon}"`)
if (!SUPPORTED_PREFIXES.has(prefix)) {
throw new InvalidIconProbeError(`Unsupported icon prefix: "${prefix}"`)
}
const iconSet = loadIconSet(prefix)
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
}
const iconSet = resolveIconSet(prefix)
if (!iconSet) {
throw new Error(
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
@@ -40,7 +57,7 @@ function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
const generated = getIconsCSSData(iconSet, [name], {
iconSelector: '.icon',
customise: (content) =>
content.replaceAll('stroke-width="2"', `stroke-width="${strokeWidth}"`)
content.replace(STROKE_WIDTH_ATTR_RE, `stroke-width="${strokeWidth}"`)
})
if (generated.css.length !== 1) {
throw new Error(`Cannot find "${icon}". Bad icon name?`)
@@ -62,8 +79,8 @@ export default plugin(({ matchComponents }) => {
try {
return getDynamicCSSRulesWithStroke(icon, sw)
} catch (err) {
console.warn(err.message)
return {}
if (err instanceof InvalidIconProbeError) return {}
throw err
}
}
})

View File

@@ -8,15 +8,12 @@
@plugin 'tailwindcss-primeui';
@plugin "@iconify/tailwind4" {
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
@plugin "./iconifyDynamicPlugin.js";
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@@ -25,6 +22,7 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
@@ -65,6 +63,9 @@
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-canvas: #c2bfb9;
--color-danger-100: #c02323;
--color-danger-200: #d62952;

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -7,7 +7,8 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -0,0 +1,35 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

2374
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,15 +9,16 @@ publicHoistPattern:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@astrojs/check': ^0.9.9
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@lobehub/i18n-cli': ^1.26.1
@@ -65,7 +66,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
astro: ^6.4.2
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
@@ -92,7 +93,7 @@ catalog:
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.14.1
knip: ^6.15.0
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -44,11 +44,14 @@
/>
</div>
<div
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
@@ -61,25 +64,29 @@
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<div
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -142,6 +149,7 @@ const {
isRecording,
isPreview,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -175,6 +183,7 @@ const {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -67,6 +67,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
@@ -140,10 +141,12 @@ function closeDialog() {
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
)
if (!node) return

View File

@@ -194,10 +194,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}
}))
vi.mock('primevue/confirmdialog', () => ({
default: { name: 'ConfirmDialog', template: '<div />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -148,11 +148,9 @@
</div>
</template>
</SidebarTabTemplate>
<ConfirmDialog />
</template>
<script setup lang="ts">
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,6 +189,7 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -321,8 +322,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, this)
}

View File

@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -1,3 +1,4 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
values?.workflow ? `${key}:${values.workflow}` : key,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
toastErrorHandler: mockToastErrorHandler
})
}))
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
)
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('shows the friendly message for a known Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/invalid-credential'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.generic'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('delegates non-Firebase errors to toastErrorHandler', () => {
const { reportError } = useAuthActions()
const networkError = new TypeError('Failed to fetch')
reportError(networkError)
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
})
} else {
toastErrorHandler(error)
}

View File

@@ -1,24 +1,28 @@
import { createPinia, setActivePinia } from 'pinia'
import { markRaw } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
Reroute
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeId } from '@/renderer/core/layout/types'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock the app module
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
selected_nodes: null
}
const mockApp = vi.hoisted(() => ({
canvas: {
selected_nodes: null as Record<string, LGraphNode> | null
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({ app: mockApp }))
// Mock the litegraph module
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
@@ -30,6 +34,27 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
}
})
// Real LGraphNode instances so the production isLGraphNode (instanceof) guard runs
// unmodified — the node accessors filter selectedItems with the real predicate.
const makeNode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.mode = mode
return node
}
const makeSubgraphNode = (
children: LGraphNode[],
overrides: { id?: number; mode?: LGraphEventMode } = {}
): LGraphNode =>
Object.assign(
makeNode(overrides.mode ?? LGraphEventMode.ALWAYS, overrides.id ?? 1),
{
isSubgraphNode: () => true,
subgraph: { nodes: children }
}
)
// Mock Positionable objects
class MockNode implements Positionable {
@@ -77,16 +102,22 @@ describe('useSelectedLiteGraphItems', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
mockApp.canvas.selected_nodes = null
// Mock canvas with selectedItems Set
mockCanvas = {
// markRaw so the spied getter's return is not reactive-wrapped by the Pinia
// store proxy — production reads a shallowRef, so nodes stay raw references.
mockCanvas = markRaw({
selectedItems: new Set<Positionable>()
}
})
// Mock getCanvas to return our mock canvas
// getSelectableItems reads getCanvas(); the node accessors read canvasStore.canvas.
// Point both at the same mock canvas.
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
)
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue(
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
)
})
describe('isIgnoredItem', () => {
@@ -207,23 +238,24 @@ describe('useSelectedLiteGraphItems', () => {
describe('node-specific methods', () => {
it('getSelectedNodes should return only LGraphNode instances', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
const node1 = makeNode(LGraphEventMode.ALWAYS, 1)
const node2 = makeNode(LGraphEventMode.NEVER, 2)
const reroute = new MockReroute()
// Mock app.canvas.selected_nodes
app.canvas.selected_nodes = { '0': node1, '1': node2 }
// The non-node (reroute) must be filtered out by isLGraphNode.
mockCanvas.selectedItems = new Set([node1, reroute, node2])
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(2)
expect(selectedNodes[0]).toBe(node1)
expect(selectedNodes[1]).toBe(node2)
expect(selectedNodes).not.toContain(reroute)
})
it('getSelectedNodes should return empty array when no nodes selected', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
// @ts-expect-error - Testing null case
app.canvas.selected_nodes = null
mockCanvas.selectedItems = new Set()
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(0)
@@ -231,10 +263,10 @@ describe('useSelectedLiteGraphItems', () => {
it('toggleSelectedNodesMode should toggle node modes correctly', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
const node1 = makeNode(LGraphEventMode.ALWAYS, 1)
const node2 = makeNode(LGraphEventMode.NEVER, 2)
app.canvas.selected_nodes = { '0': node1, '1': node2 }
mockCanvas.selectedItems = new Set([node1, node2])
// Toggle to NEVER mode
toggleSelectedNodesMode(LGraphEventMode.NEVER)
@@ -247,9 +279,9 @@ describe('useSelectedLiteGraphItems', () => {
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node = makeNode(LGraphEventMode.BYPASS, 1)
app.canvas.selected_nodes = { '0': node }
mockCanvas.selectedItems = new Set([node])
// Toggle to BYPASS mode (node is already BYPASS)
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
@@ -258,31 +290,59 @@ describe('useSelectedLiteGraphItems', () => {
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
})
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const node1 = makeNode(LGraphEventMode.BYPASS, 1)
const node2 = makeNode(LGraphEventMode.BYPASS, 2)
mockCanvas.selectedItems = new Set([node1, node2])
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const bypassed = makeNode(LGraphEventMode.BYPASS, 1)
const active = makeNode(LGraphEventMode.ALWAYS, 2)
mockCanvas.selectedItems = new Set([bypassed, active])
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('areAllSelectedNodesInMode returns false for empty selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
mockCanvas.selectedItems = new Set()
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
const subNode2 = makeNode(LGraphEventMode.NEVER, 12)
const subgraphNode = makeSubgraphNode([subNode1, subNode2])
const regularNode = makeNode(LGraphEventMode.NEVER, 2)
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
mockCanvas.selectedItems = new Set([subgraphNode, regularNode])
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(4) // subgraphNode + 2 sub nodes + regularNode
expect(selectedNodes).toContainEqual(subgraphNode)
expect(selectedNodes).toContainEqual(regularNode)
expect(selectedNodes).toContainEqual(subNode1)
expect(selectedNodes).toContainEqual(subNode2)
expect(selectedNodes).toContain(subgraphNode)
expect(selectedNodes).toContain(regularNode)
expect(selectedNodes).toContain(subNode1)
expect(selectedNodes).toContain(subNode2)
})
it('toggleSelectedNodesMode should not apply state to subgraph children', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
const subNode2 = makeNode(LGraphEventMode.NEVER, 12)
const subgraphNode = makeSubgraphNode([subNode1, subNode2])
const regularNode = makeNode(LGraphEventMode.BYPASS, 2)
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
mockCanvas.selectedItems = new Set([subgraphNode, regularNode])
// Toggle to NEVER mode
toggleSelectedNodesMode(LGraphEventMode.NEVER)
@@ -300,14 +360,14 @@ describe('useSelectedLiteGraphItems', () => {
it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2], {
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
const subNode2 = makeNode(LGraphEventMode.BYPASS, 12)
const subgraphNode = makeSubgraphNode([subNode1, subNode2], {
id: 1,
mode: LGraphEventMode.NEVER // Already in NEVER mode
})
app.canvas.selected_nodes = { '0': subgraphNode }
mockCanvas.selectedItems = new Set([subgraphNode])
// Toggle to NEVER mode (but subgraphNode is already NEVER)
toggleSelectedNodesMode(LGraphEventMode.NEVER)
@@ -319,6 +379,32 @@ describe('useSelectedLiteGraphItems', () => {
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.BYPASS)
})
it('reads only the canonical selectedItems set, ignoring the legacy dict', () => {
const { getSelectedNodes, areAllSelectedNodesInMode } =
useSelectedLiteGraphItems()
const selected = makeNode(LGraphEventMode.BYPASS, 1)
const legacyOnly = makeNode(LGraphEventMode.ALWAYS, 99)
mockCanvas.selectedItems = new Set([selected])
// A different node lives only in the legacy dict; it must be ignored.
mockApp.canvas.selected_nodes = { '0': legacyOnly }
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(1)
expect(selectedNodes).toContain(selected)
expect(selectedNodes).not.toContain(legacyOnly)
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('returns empty without throwing when the canvas is unavailable', () => {
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue(null)
const { getSelectedNodes, areAllSelectedNodesInMode } =
useSelectedLiteGraphItems()
expect(getSelectedNodes()).toEqual([])
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
})
describe('dynamic behavior', () => {

View File

@@ -1,8 +1,8 @@
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -63,6 +63,16 @@ export function useSelectedLiteGraphItems() {
return getSelectableItems().size > 1
}
/**
* The top-level selected nodes from the canonical selection set.
* Shallow — does NOT expand subgraph children, unlike {@link getSelectedNodes}.
* Mode toggles use this so they apply to the selected subgraph node, not its
* descendants. Returns `[]` when the canvas is not yet available, preserving
* the prior null-tolerance for callers wired to early-firing commands.
*/
const getSelectedNodesShallow = (): LGraphNode[] =>
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.
* This filters out other types of selected items like groups or reroutes.
@@ -70,29 +80,29 @@ export function useSelectedLiteGraphItems() {
* @returns Array of selected LGraphNode instances and their descendants.
*/
const getSelectedNodes = (): LGraphNode[] => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return []
const nodeArray = getSelectedNodesShallow()
// Convert selected_nodes object to array, preserving order
const nodeArray: LGraphNode[] = []
for (const i in selectedNodes) {
nodeArray.push(selectedNodes[i])
}
// Check if any selected nodes are subgraphs
const hasSubgraphs = nodeArray.some(
(node) => node.isSubgraphNode?.() && node.subgraph
)
if (!hasSubgraphs) return nodeArray
// If no subgraphs, just return the array directly to preserve order
if (!hasSubgraphs) {
return nodeArray
}
// Use collectFromNodes to get all nodes including those in subgraphs
return collectFromNodes(nodeArray)
}
/**
* True iff every selected node is in `mode`. Mirrors the predicate used by
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
* An empty selection returns `false` (no node is in the mode).
*/
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
const selectedNodeArray = getSelectedNodesShallow()
return (
selectedNodeArray.length > 0 &&
selectedNodeArray.every((node) => node.mode === mode)
)
}
/**
* Toggle the execution mode of all selected nodes
*
@@ -102,18 +112,10 @@ export function useSelectedLiteGraphItems() {
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return
// Convert selected_nodes object to array
const selectedNodeArray: LGraphNode[] = []
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
const selectedNodeArray = getSelectedNodesShallow()
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
? LGraphEventMode.ALWAYS
: mode
for (const selectedNode of selectedNodeArray)
selectedNode.mode = newModeForSelectedNode
@@ -126,6 +128,7 @@ export function useSelectedLiteGraphItems() {
hasSelectableItems,
hasMultipleSelectableItems,
getSelectedNodes,
areAllSelectedNodesInMode,
toggleSelectedNodesMode
}
}

View File

@@ -137,6 +137,51 @@ describe('contextMenuConverter', () => {
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
const legacyOptions = convertContextMenuToOptions(
[{ content: 'Bypass', callback: () => {} }],
undefined,
false
)
expect(
legacyOptions.find(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
).toBeUndefined()
const vueBypass: MenuOption = {
label: 'Remove Bypass',
icon: 'icon-[lucide--redo-dot]',
shortcut: 'Ctrl+B',
action: () => {},
source: 'vue'
}
const result = buildStructuredMenu([...legacyOptions, vueBypass])
const bypassItems = result.filter(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
expect(bypassItems).toHaveLength(1)
expect(bypassItems[0].source).toBe('vue')
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
})
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
const options: MenuOption[] = [
{ label: 'Bypass', action: () => {}, source: 'vue' },
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const labels = result
.map((opt) => opt.label)
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
expect(labels).toEqual(
expect.arrayContaining(['Bypass', 'Remove Bypass'])
)
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },

View File

@@ -21,7 +21,10 @@ const HARD_BLACKLIST = new Set([
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
'Copy (Clipspace)',
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
'Bypass',
'Remove Bypass'
])
/**

View File

@@ -211,7 +211,7 @@ export function useMoreOptionsMenu() {
}
if (!groupContext) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(states, bump)
const bypass = getBypassOption(bump)
options.push(pin)
options.push(bypass)
}

View File

@@ -0,0 +1,99 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.mode = mode
return node
}
const getBypassLabel = (selected: LGraphNode[]): string => {
const canvasStore = useCanvasStore()
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue({
selectedItems: new Set<Positionable>(selected)
} as ReturnType<typeof canvasStore.getCanvas>)
let label = ''
const Wrapper = defineComponent({
setup() {
const { getBypassOption } = useNodeMenuOptions()
label = getBypassOption(() => {}).label ?? ''
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('labels as "Bypass" when no node is bypassed', () => {
expect(getBypassLabel([nodeWithMode(LGraphEventMode.ALWAYS, 1)])).toBe(
'contextMenu.Bypass'
)
})
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
expect(
getBypassLabel([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.BYPASS, 2)
])
).toBe('contextMenu.Remove Bypass')
})
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
expect(
getBypassLabel([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.ALWAYS, 2)
])
).toBe('contextMenu.Bypass')
})
})

View File

@@ -1,6 +1,9 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
@@ -20,6 +23,7 @@ export function useNodeMenuOptions() {
toggleNodeBypass,
runBranch
} = useSelectedNodeActions()
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const shapeSubmenu = computed(() =>
shapeOptions.map((shape) => ({
@@ -91,11 +95,8 @@ export function useNodeMenuOptions() {
}
})
const getBypassOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.bypassed
const getBypassOption = (bump: () => void): MenuOption => ({
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: 'icon-[lucide--redo-dot]',

View File

@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -13,7 +13,6 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
collapsed: boolean
pinned: boolean
bypassed: boolean
}
/**
@@ -78,12 +77,10 @@ export function useSelectionState() {
const computeSelectionStatesFromNodes = (
nodes: LGraphNode[]
): NodeSelectionState => {
if (!nodes.length)
return { collapsed: false, pinned: false, bypassed: false }
if (!nodes.length) return { collapsed: false, pinned: false }
return {
collapsed: nodes.some((n) => n.flags?.collapsed),
pinned: nodes.some((n) => n.pinned),
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
pinned: nodes.some((n) => n.pinned)
}
}

View File

@@ -1,5 +1,6 @@
import { ref, shallowRef } from 'vue'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const nodeDef = draggedNode.value
if (!nodeDef) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
return true
}

View File

@@ -3,7 +3,7 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'

View File

@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
@@ -146,9 +147,11 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) return

View File

@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app as comfyApp } from '@/scripts/app'
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
)
targetProvider = provider
}

View File

@@ -1503,9 +1503,11 @@ describe('useLoad3d', () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const call = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.find(([event]) => event === 'modelReady')
const addEventListenerCalls = vi.mocked(mockLoad3d.addEventListener!).mock
.calls
const call = addEventListenerCalls.find(
([event]) => event === 'modelReady'
)
return { composable, handler: call![1] as () => void }
}
@@ -1514,9 +1516,9 @@ describe('useLoad3d', () => {
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const events = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.map(([event]) => event)
const addEventListenerCalls = vi.mocked(mockLoad3d.addEventListener!).mock
.calls
const events = addEventListenerCalls.map(([event]) => event)
expect(events).toContain('modelReady')
expect(events).toContain('modelLoadingEnd')
expect(composable).toBeDefined()

View File

@@ -132,6 +132,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
@@ -853,6 +854,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
canUseGizmo.value = caps?.gizmoTransform ?? true
@@ -971,6 +973,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
syncSceneModels()
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
}
const handleResetGizmoTransform = () => {
if (load3d) {
load3d.resetGizmoTransform()
@@ -1011,6 +1017,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel,
isPlyModel,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -1046,6 +1053,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
}
}

View File

@@ -4,6 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -368,7 +369,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = node.type === 'Preview3D'
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
import { resolvePreviewExposureChain } from './previewExposureChain'

View File

@@ -1,5 +1,5 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { UUID } from '@/utils/uuid'
interface ResolvedPreviewChainStep {
rootGraphId: UUID

View File

@@ -164,6 +164,7 @@ function makeLoad3DNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
size: [300, 600],
setSize: vi.fn(),
addWidget: vi.fn(),
widgets: overrides.widgets ?? [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },

View File

@@ -219,13 +219,15 @@ useExtensionService().registerExtension({
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
name: 'Point Cloud Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
options: ['threejs', 'fastply'],
defaultValue: 'threejs',
migrateDeprecatedValue: (value) =>
value === 'sparkjs' ? 'threejs' : value,
experimental: true
}
],
@@ -267,40 +269,44 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
if (node.constructor.comfyClass === 'Load3D') {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
})
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
)
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
}
const widget = new ComponentWidgetImpl({
node: node,

View File

@@ -161,11 +161,23 @@ class Load3d {
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
}
private async repaintWhenSparkPaintable(): Promise<void> {
const sortComplete = this.sceneManager.awaitNextSparkDirty()
this.forceRender()
await sortComplete
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
@@ -626,7 +638,7 @@ class Load3d {
}
getCurrentModelCapabilities(): ModelAdapterCapabilities {
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
}
clearModel(): void {
@@ -924,6 +936,22 @@ class Load3d {
this.forceRender()
}
public centerCameraOnModel(): void {
const bounds = this.modelManager.getCurrentBounds()
if (!bounds || bounds.isEmpty()) return
const center = bounds.getCenter(new THREE.Vector3())
const camera = this.cameraManager.activeCamera
const controls = this.controlsManager.controls
const offset = center.clone().sub(camera.position)
camera.position.add(offset)
controls.target.add(offset)
camera.updateMatrixWorld(true)
controls.update()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()

View File

@@ -7,7 +7,11 @@ import type {
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
function makeEventManagerStub() {
return {
@@ -28,6 +32,12 @@ type ModelManagerStub = {
originalURL: string | null
}
const STUB_CAPS = {} as ModelAdapterCapabilities
const loadResult = (object: THREE.Object3D) => ({
object,
capabilities: STUB_CAPS
})
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
@@ -41,14 +51,21 @@ function makeModelManagerStub(): ModelManagerStub {
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
const {
meshLoad,
splatLoad,
pointCloudLoad,
fetchModelDataMock,
isGaussianSplatPLYMock,
addAlert
} = vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
@@ -65,19 +82,35 @@ vi.mock('./PointCloudModelAdapter', () => ({
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
},
getPLYEngine: () => getPLYEngineMock()
}
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly capabilities = {}
matches = async (
ext: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> => {
if (ext !== 'ply') return true
return isGaussianSplatPLYMock(await fetchBytes())
}
load = splatLoad
}
}))
vi.mock('./ModelAdapter', async () => {
const actual =
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
return { ...actual, fetchModelData: fetchModelDataMock }
})
vi.mock('@/scripts/metadata/ply', () => ({
isGaussianSplatPLY: isGaussianSplatPLYMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
@@ -87,7 +120,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null>
}
function makeLoaderManager() {
@@ -98,21 +134,21 @@ function makeLoaderManager() {
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
const pick = (ext: string) =>
internals.pickAdapter.call(lm, ext, () =>
fetchModelDataMock()
) as Promise<ModelAdapter | null>
return { lm, modelManager, eventManager, pick }
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
isGaussianSplatPLYMock.mockResolvedValue(false)
})
describe('getCurrentAdapter', () => {
@@ -123,7 +159,7 @@ describe('LoaderManager', () => {
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -132,7 +168,7 @@ describe('LoaderManager', () => {
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -144,7 +180,7 @@ describe('LoaderManager', () => {
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -195,7 +231,10 @@ describe('LoaderManager', () => {
}
let adapterDuringClear: ModelAdapter | null | undefined
const adapterRef = { current: oldAdapter as ModelAdapter | null }
const adapterRef = {
current: oldAdapter as ModelAdapter | null,
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
}
const lm = new LoaderManager(
modelManager,
eventManager,
@@ -223,8 +262,8 @@ describe('LoaderManager', () => {
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
resolveSplatLoad = resolve
})
splatLoad.mockReturnValueOnce(slowSplatLoad)
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
const aPromise = lm.loadModel('api/view?filename=a.splat')
@@ -243,42 +282,36 @@ describe('LoaderManager', () => {
describe('pickAdapter', () => {
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
expect((await pick(ext))?.kind).toBe('mesh')
}
)
it.for(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
expect((await pick(ext))?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('splat')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('returns null for unknown extensions', async () => {
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
expect(await pick('xyz')).toBeNull()
expect(await pick('')).toBeNull()
})
})
@@ -348,7 +381,7 @@ describe('LoaderManager', () => {
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
meshLoad.mockResolvedValueOnce(loadResult(loaded))
await lm.loadModel('api/view?filename=cube.glb')
@@ -366,7 +399,7 @@ describe('LoaderManager', () => {
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -378,7 +411,7 @@ describe('LoaderManager', () => {
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
@@ -390,32 +423,105 @@ describe('LoaderManager', () => {
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
})
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
const buf = new ArrayBuffer(16)
fetchModelDataMock.mockResolvedValueOnce(buf)
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
// Adapter receives a fetchBytes function (memoized), not bytes directly.
expect(splatLoad).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
'scan.ply',
expect.any(Function)
)
// matches() called fetchBytes once; load()'s call hit the cached promise.
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
})
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
const modelManager =
makeModelManagerStub() as unknown as ConstructorParameters<
typeof LoaderManager
>[0]
const eventManager = makeEventManagerStub()
// A splat adapter that ALSO claims '.ply' and is listed first.
// Without matches(), it would short-circuit. With matches() returning
// false (not a 3DGS PLY), the dispatcher must skip to the next
// candidate (point cloud).
const splatAdapter = {
kind: 'splat' as const,
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
capabilities: {} as never,
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
load: splatLoad
}
const pointCloudAdapter = {
kind: 'pointCloud' as const,
extensions: ['ply'] as const,
capabilities: {} as never,
load: pointCloudLoad
}
const lm = new LoaderManager(modelManager, eventManager, [
splatAdapter,
pointCloudAdapter
])
isGaussianSplatPLYMock.mockResolvedValue(false)
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
@@ -498,8 +604,8 @@ describe('LoaderManager', () => {
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
.mockImplementationOnce(() => firstLoad.then(loadResult))
.mockResolvedValueOnce(loadResult(secondModel))
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')

View File

@@ -4,9 +4,14 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { createAdapterRef, fetchModelData } from './ModelAdapter'
import type {
AdapterRef,
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
@@ -36,14 +41,16 @@ function isNotFoundError(error: unknown): boolean {
}
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
* file extensions it owns. For shared extensions (.ply), the adapter with an
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
new SplatModelAdapter(),
new PointCloudModelAdapter()
]
}
@@ -86,6 +93,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.adapterRef.current = null
this.adapterRef.capabilities = null
this.modelManager.originalURL = url
@@ -122,7 +130,8 @@ export class LoaderManager implements LoaderManagerInterface {
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
await this.modelManager.setupModel(result.model)
this.adapterRef.capabilities = result.capabilities
await this.modelManager.setupModel(result.object)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
@@ -137,19 +146,18 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
private async pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null> {
const candidates = this.adapters.filter((a) =>
a.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
for (const adapter of candidates) {
if (!adapter.matches) return adapter
if (await adapter.matches(extension, fetchBytes)) return adapter
}
return match
return null
}
private createLoadContext(): ModelLoadContext {
@@ -170,7 +178,11 @@ export class LoaderManager implements LoaderManagerInterface {
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
): Promise<{
object: THREE.Object3D
adapter: ModelAdapter
capabilities: ModelAdapterCapabilities
} | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
@@ -188,10 +200,24 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
const adapter = this.pickAdapter(fileExtension)
let bytesPromise: Promise<ArrayBuffer> | null = null
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
if (!adapter) return null
const model = await adapter.load(this.createLoadContext(), path, filename)
return model ? { model, adapter } : null
const loadResult = await adapter.load(
this.createLoadContext(),
path,
filename,
fetchBytes
)
return loadResult
? {
object: loadResult.object,
capabilities: loadResult.capabilities,
adapter
}
: null
}
}

View File

@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
expect(result!.object).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
expect(result!.object).toBe(scene)
})
it('also handles .gltf filenames', async () => {

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