Compare commits

...

9 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
77 changed files with 2342 additions and 550 deletions

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

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

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

@@ -14,6 +14,7 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
@@ -32,6 +33,7 @@ type Routes = typeof baseRoutes
//
// termsOfService: legal-reviewed English-only document, same reasoning.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService'
])
@@ -48,6 +50,7 @@ export function getRoutes(locale: Locale = 'en'): 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': '美国旧金山'
@@ -4418,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

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

View File

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

View File

@@ -13,7 +13,7 @@
@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}]");
@@ -22,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);
@@ -62,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

372
pnpm-lock.yaml generated
View File

@@ -259,8 +259,8 @@ catalogs:
specifier: ^2.1.0
version: 2.1.0
knip:
specifier: ^6.14.1
version: 6.14.2
specifier: ^6.15.0
version: 6.15.0
lenis:
specifier: ^1.3.21
version: 1.3.21
@@ -762,7 +762,7 @@ importers:
version: 27.4.0
knip:
specifier: 'catalog:'
version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
version: 6.15.0
lint-staged:
specifier: 'catalog:'
version: 16.4.0
@@ -2515,244 +2515,239 @@ packages:
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@oxc-parser/binding-android-arm-eabi@0.130.0':
resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==}
'@oxc-parser/binding-android-arm-eabi@0.133.0':
resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxc-parser/binding-android-arm64@0.130.0':
resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==}
'@oxc-parser/binding-android-arm64@0.133.0':
resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.130.0':
resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==}
'@oxc-parser/binding-darwin-arm64@0.133.0':
resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.130.0':
resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==}
'@oxc-parser/binding-darwin-x64@0.133.0':
resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.130.0':
resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==}
'@oxc-parser/binding-freebsd-x64@0.133.0':
resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==}
'@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==}
'@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==}
'@oxc-parser/binding-linux-arm64-gnu@0.133.0':
resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==}
'@oxc-parser/binding-linux-arm64-musl@0.133.0':
resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==}
'@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==}
'@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==}
'@oxc-parser/binding-linux-riscv64-musl@0.133.0':
resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==}
'@oxc-parser/binding-linux-s390x-gnu@0.133.0':
resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==}
'@oxc-parser/binding-linux-x64-gnu@0.133.0':
resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.130.0':
resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==}
'@oxc-parser/binding-linux-x64-musl@0.133.0':
resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.130.0':
resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==}
'@oxc-parser/binding-openharmony-arm64@0.133.0':
resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxc-parser/binding-wasm32-wasi@0.130.0':
resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==}
'@oxc-parser/binding-wasm32-wasi@0.133.0':
resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==}
'@oxc-parser/binding-win32-arm64-msvc@0.133.0':
resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==}
'@oxc-parser/binding-win32-ia32-msvc@0.133.0':
resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==}
'@oxc-parser/binding-win32-x64-msvc@0.133.0':
resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@oxc-resolver/binding-android-arm-eabi@11.20.0':
resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==}
cpu: [arm]
os: [android]
'@oxc-resolver/binding-android-arm64@11.19.1':
resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
'@oxc-resolver/binding-android-arm64@11.20.0':
resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==}
cpu: [arm64]
os: [android]
'@oxc-resolver/binding-darwin-arm64@11.19.1':
resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
'@oxc-resolver/binding-darwin-arm64@11.20.0':
resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==}
cpu: [arm64]
os: [darwin]
'@oxc-resolver/binding-darwin-x64@11.19.1':
resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
'@oxc-resolver/binding-darwin-x64@11.20.0':
resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==}
cpu: [x64]
os: [darwin]
'@oxc-resolver/binding-freebsd-x64@11.19.1':
resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
'@oxc-resolver/binding-freebsd-x64@11.20.0':
resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==}
cpu: [x64]
os: [freebsd]
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
'@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
'@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
'@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
'@oxc-resolver/binding-linux-arm64-musl@11.20.0':
resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
'@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
'@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
'@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
'@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
'@oxc-resolver/binding-linux-x64-gnu@11.20.0':
resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
'@oxc-resolver/binding-linux-x64-musl@11.20.0':
resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
'@oxc-resolver/binding-openharmony-arm64@11.20.0':
resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==}
cpu: [arm64]
os: [openharmony]
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
'@oxc-resolver/binding-wasm32-wasi@11.20.0':
resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
'@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==}
cpu: [arm64]
os: [win32]
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
cpu: [ia32]
os: [win32]
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==}
cpu: [x64]
os: [win32]
@@ -6347,8 +6342,8 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
knip@6.14.2:
resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==}
knip@6.15.0:
resolution: {integrity: sha512-uBaKFEGcu/HG4EY2gWFBMr+fBF43Jftoc2riJX51TKME1Z46C8UQIbNEusenYbEWihphxe2PY0Kns0yPvPYz4A==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -6982,12 +6977,12 @@ packages:
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
oxc-parser@0.130.0:
resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==}
oxc-parser@0.133.0:
resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.19.1:
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
oxc-resolver@11.20.0:
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
oxfmt@0.44.0:
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
@@ -10554,137 +10549,133 @@ snapshots:
'@oslojs/encoding@1.1.0': {}
'@oxc-parser/binding-android-arm-eabi@0.130.0':
'@oxc-parser/binding-android-arm-eabi@0.133.0':
optional: true
'@oxc-parser/binding-android-arm64@0.130.0':
'@oxc-parser/binding-android-arm64@0.133.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.130.0':
'@oxc-parser/binding-darwin-arm64@0.133.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.130.0':
'@oxc-parser/binding-darwin-x64@0.133.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.130.0':
'@oxc-parser/binding-freebsd-x64@0.133.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
'@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
'@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
'@oxc-parser/binding-linux-arm64-gnu@0.133.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
'@oxc-parser/binding-linux-arm64-musl@0.133.0':
optional: true
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
'@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
'@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
optional: true
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
'@oxc-parser/binding-linux-riscv64-musl@0.133.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
'@oxc-parser/binding-linux-s390x-gnu@0.133.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
'@oxc-parser/binding-linux-x64-gnu@0.133.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.130.0':
'@oxc-parser/binding-linux-x64-musl@0.133.0':
optional: true
'@oxc-parser/binding-openharmony-arm64@0.130.0':
'@oxc-parser/binding-openharmony-arm64@0.133.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.130.0':
'@oxc-parser/binding-wasm32-wasi@0.133.0':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
'@oxc-parser/binding-win32-arm64-msvc@0.133.0':
optional: true
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
'@oxc-parser/binding-win32-ia32-msvc@0.133.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
'@oxc-parser/binding-win32-x64-msvc@0.133.0':
optional: true
'@oxc-project/types@0.130.0': {}
'@oxc-project/types@0.132.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
'@oxc-project/types@0.133.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.20.0':
optional: true
'@oxc-resolver/binding-android-arm64@11.19.1':
'@oxc-resolver/binding-android-arm64@11.20.0':
optional: true
'@oxc-resolver/binding-darwin-arm64@11.19.1':
'@oxc-resolver/binding-darwin-arm64@11.20.0':
optional: true
'@oxc-resolver/binding-darwin-x64@11.19.1':
'@oxc-resolver/binding-darwin-x64@11.20.0':
optional: true
'@oxc-resolver/binding-freebsd-x64@11.19.1':
'@oxc-resolver/binding-freebsd-x64@11.20.0':
optional: true
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
'@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
optional: true
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
'@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
optional: true
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
'@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
optional: true
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
'@oxc-resolver/binding-linux-arm64-musl@11.20.0':
optional: true
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
'@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
optional: true
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
'@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
optional: true
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
'@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
optional: true
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
'@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
optional: true
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
'@oxc-resolver/binding-linux-x64-gnu@11.20.0':
optional: true
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
'@oxc-resolver/binding-linux-x64-musl@11.20.0':
optional: true
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
'@oxc-resolver/binding-openharmony-arm64@11.20.0':
optional: true
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
'@oxc-resolver/binding-wasm32-wasi@11.20.0':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
'@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
optional: true
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
optional: true
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.44.0':
@@ -14478,15 +14469,15 @@ snapshots:
kleur@4.1.5: {}
knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
knip@6.15.0:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
formatly: 0.3.0
get-tsconfig: 4.14.0
jiti: 2.7.0
minimist: 1.2.8
oxc-parser: 0.130.0
oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
oxc-parser: 0.133.0
oxc-resolver: 11.20.0
picomatch: 4.0.4
smol-toml: 1.6.1
strip-json-comments: 5.0.3
@@ -14494,9 +14485,6 @@ snapshots:
unbash: 3.0.0
yaml: 2.9.0
zod: 4.3.6
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
known-css-properties@0.37.0: {}
@@ -15295,56 +15283,52 @@ snapshots:
orderedmap@2.1.1: {}
oxc-parser@0.130.0:
oxc-parser@0.133.0:
dependencies:
'@oxc-project/types': 0.130.0
'@oxc-project/types': 0.133.0
optionalDependencies:
'@oxc-parser/binding-android-arm-eabi': 0.130.0
'@oxc-parser/binding-android-arm64': 0.130.0
'@oxc-parser/binding-darwin-arm64': 0.130.0
'@oxc-parser/binding-darwin-x64': 0.130.0
'@oxc-parser/binding-freebsd-x64': 0.130.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.130.0
'@oxc-parser/binding-linux-arm64-gnu': 0.130.0
'@oxc-parser/binding-linux-arm64-musl': 0.130.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.130.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.130.0
'@oxc-parser/binding-linux-riscv64-musl': 0.130.0
'@oxc-parser/binding-linux-s390x-gnu': 0.130.0
'@oxc-parser/binding-linux-x64-gnu': 0.130.0
'@oxc-parser/binding-linux-x64-musl': 0.130.0
'@oxc-parser/binding-openharmony-arm64': 0.130.0
'@oxc-parser/binding-wasm32-wasi': 0.130.0
'@oxc-parser/binding-win32-arm64-msvc': 0.130.0
'@oxc-parser/binding-win32-ia32-msvc': 0.130.0
'@oxc-parser/binding-win32-x64-msvc': 0.130.0
'@oxc-parser/binding-android-arm-eabi': 0.133.0
'@oxc-parser/binding-android-arm64': 0.133.0
'@oxc-parser/binding-darwin-arm64': 0.133.0
'@oxc-parser/binding-darwin-x64': 0.133.0
'@oxc-parser/binding-freebsd-x64': 0.133.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.133.0
'@oxc-parser/binding-linux-arm64-gnu': 0.133.0
'@oxc-parser/binding-linux-arm64-musl': 0.133.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.133.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.133.0
'@oxc-parser/binding-linux-riscv64-musl': 0.133.0
'@oxc-parser/binding-linux-s390x-gnu': 0.133.0
'@oxc-parser/binding-linux-x64-gnu': 0.133.0
'@oxc-parser/binding-linux-x64-musl': 0.133.0
'@oxc-parser/binding-openharmony-arm64': 0.133.0
'@oxc-parser/binding-wasm32-wasi': 0.133.0
'@oxc-parser/binding-win32-arm64-msvc': 0.133.0
'@oxc-parser/binding-win32-ia32-msvc': 0.133.0
'@oxc-parser/binding-win32-x64-msvc': 0.133.0
oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
oxc-resolver@11.20.0:
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
'@oxc-resolver/binding-android-arm64': 11.19.1
'@oxc-resolver/binding-darwin-arm64': 11.19.1
'@oxc-resolver/binding-darwin-x64': 11.19.1
'@oxc-resolver/binding-freebsd-x64': 11.19.1
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
'@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
'@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
'@oxc-resolver/binding-linux-arm64-musl': 11.19.1
'@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
'@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@oxc-resolver/binding-android-arm-eabi': 11.20.0
'@oxc-resolver/binding-android-arm64': 11.20.0
'@oxc-resolver/binding-darwin-arm64': 11.20.0
'@oxc-resolver/binding-darwin-x64': 11.20.0
'@oxc-resolver/binding-freebsd-x64': 11.20.0
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0
'@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0
'@oxc-resolver/binding-linux-arm64-gnu': 11.20.0
'@oxc-resolver/binding-linux-arm64-musl': 11.20.0
'@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0
'@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0
'@oxc-resolver/binding-linux-riscv64-musl': 11.20.0
'@oxc-resolver/binding-linux-s390x-gnu': 11.20.0
'@oxc-resolver/binding-linux-x64-gnu': 11.20.0
'@oxc-resolver/binding-linux-x64-musl': 11.20.0
'@oxc-resolver/binding-openharmony-arm64': 11.20.0
'@oxc-resolver/binding-wasm32-wasi': 11.20.0
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
oxfmt@0.44.0:
dependencies:

View File

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

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

@@ -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)
@@ -260,20 +292,20 @@ describe('useSelectedLiteGraphItems', () => {
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
const node1 = makeNode(LGraphEventMode.BYPASS, 1)
const node2 = makeNode(LGraphEventMode.BYPASS, 2)
app.canvas.selected_nodes = { '0': node1, '1': node2 }
mockCanvas.selectedItems = new Set([node1, node2])
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
const bypassed = makeNode(LGraphEventMode.BYPASS, 1)
const active = makeNode(LGraphEventMode.ALWAYS, 2)
app.canvas.selected_nodes = { '0': bypassed, '1': active }
mockCanvas.selectedItems = new Set([bypassed, active])
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
@@ -281,36 +313,36 @@ describe('useSelectedLiteGraphItems', () => {
it('areAllSelectedNodesInMode returns false for empty selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
app.canvas.selected_nodes = {}
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)
@@ -328,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)
@@ -347,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,32 +80,16 @@ 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)
}
const getSelectedNodesShallow = (): LGraphNode[] =>
Object.values(app.canvas.selected_nodes ?? {})
/**
* True iff every selected node is in `mode`. Mirrors the predicate used by
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.

View File

@@ -5,17 +5,16 @@ import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
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: { canvas: { selected_nodes: null } }
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
@@ -44,18 +43,19 @@ const i18n = createI18n({
fallbackWarn: false
})
const setSelectedNodes = (nodes: LGraphNode[]) => {
const dict: Record<string, LGraphNode> = {}
nodes.forEach((n, i) => {
dict[String(i)] = n
})
mockApp.canvas.selected_nodes = dict
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.mode = mode
return node
}
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
({ id, mode }) as LGraphNode
const getBypassLabel = (selected: LGraphNode[]): string => {
const canvasStore = useCanvasStore()
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue({
selectedItems: new Set<Positionable>(selected)
} as ReturnType<typeof canvasStore.getCanvas>)
const getBypassLabel = (): string => {
let label = ''
const Wrapper = defineComponent({
setup() {
@@ -71,27 +71,29 @@ const getBypassLabel = (): string => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockApp.canvas.selected_nodes = null
})
it('labels as "Bypass" when no node is bypassed', () => {
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
expect(getBypassLabel([nodeWithMode(LGraphEventMode.ALWAYS, 1)])).toBe(
'contextMenu.Bypass'
)
})
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.BYPASS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
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', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.ALWAYS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
expect(
getBypassLabel([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.ALWAYS, 2)
])
).toBe('contextMenu.Bypass')
})
})

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

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

@@ -2291,7 +2291,8 @@
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
"auth/network-request-failed": "Network error. Please check your connection and try again.",
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
"generic": "Something went wrong while signing you in. Please try again."
},
"deleteAccount": {
"contactSupport": "To delete your account, please contact {email}"
@@ -2962,6 +2963,29 @@
"cloudStart_learnAboutButton": "Learn about Cloud",
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
"cloudStart_download": "Download ComfyUI",
"cloudHero": {
"previousSlide": "Previous slide",
"nextSlide": "Next slide",
"slidePagerLabel": "Go to slide {index}",
"slides": {
"cloud": {
"title": "Cloud",
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
},
"workflows": {
"title": "Workflows",
"description": "From idea to output in minutes. Generate multiple variations side by side."
},
"team": {
"title": "Team",
"description": "Onboard your team today. Share workflows and assets across your organization."
},
"models": {
"title": "Models",
"description": "Curated, commercially licensed models ready to run with zero setup."
}
}
},
"cloudWaitlist_questionsText": "Questions? Contact us",
"cloudWaitlist_contactLink": "here",
"cloudSorryContactSupport_title": "Sorry, contact support",

View File

@@ -6,6 +6,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
@@ -61,7 +62,8 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
variants.push(`${name} [input]`)
}
}
if (asset.asset_hash) variants.push(asset.asset_hash)
const hash = asset.hash ?? asset.asset_hash
if (hash) variants.push(hash)
return variants
}
@@ -279,9 +281,11 @@ export function useMediaAssetActions() {
return
}
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) {
toast.add({
@@ -296,12 +300,11 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && targetAsset.asset_hash
? targetAsset.asset_hash
: targetAsset.name
// In Cloud mode, use the content hash (the actual stored filename),
// preferring hash and falling back to the deprecated asset_hash alias.
// In OSS mode, use the original name.
const cloudHash = targetAsset.hash ?? targetAsset.asset_hash
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -425,12 +428,14 @@ export function useMediaAssetActions() {
}
const center = litegraphService.getCanvasCenter()
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
)
if (!node) {
failed++
@@ -440,10 +445,11 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
// In Cloud mode, use the content hash (the actual stored filename),
// preferring hash and falling back to the deprecated asset_hash alias.
// In OSS mode, use the original name.
const cloudHash = asset.hash ?? asset.asset_hash
const filename = isCloud && cloudHash ? cloudHash : asset.name
const annotated = createAnnotatedPath(
{

View File

@@ -5,6 +5,7 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
hash: z.string().nullish(),
asset_hash: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),

View File

@@ -206,5 +206,5 @@ export function getAssetCardTitle(asset: AssetItem): string {
* values or media URLs that must round-trip through the view endpoint.
*/
export function getAssetUrlFilename(asset: AssetItem): string {
return asset.asset_hash || asset.name
return asset.hash ?? asset.asset_hash ?? asset.name
}

View File

@@ -5,6 +5,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
name: string
hash?: string
asset_hash?: string
preview_url?: string
preview_id?: string | null
@@ -42,7 +43,7 @@ export async function findOutputAsset(
name: string
): Promise<AssetRecord | undefined> {
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_hash === name)
const hashMatch = byHash.find((a) => (a.hash ?? a.asset_hash) === name)
if (hashMatch) return hashMatch
const byName = await fetchAssets({ name_contains: name })

View File

@@ -124,5 +124,11 @@ const handleSubmit = async () => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
</style>

View File

@@ -1,97 +1,83 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl/normal font-medium">
<div
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
>
<div class="flex w-full max-w-md flex-col items-start">
<div class="flex w-full flex-col gap-4">
<h1
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ t('auth.login.title') }}
</h1>
<i18n-t
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
:plural="freeTierCredits ?? undefined"
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
>
<template #signUp>
<span
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</template>
<template #credits>{{ freeTierCredits }}</template>
</i18n-t>
<p v-else class="my-0 text-base text-muted">
{{ t('auth.login.newUser') }}
<span
class="cursor-pointer text-blue-500"
class="cursor-pointer text-azure-600"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
<template v-if="!showEmailForm">
<Button
v-if="!googleSsoBlockedReason"
type="button"
class="h-10 w-full"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
<i class="pi pi-google text-base" />
{{ t('auth.login.loginWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-charcoal-500"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
<i class="pi pi-github text-base" />
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
</template>
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="secondary"
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-primary-comfy-canvas/10"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
</template>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<p
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -99,7 +85,7 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
@@ -117,7 +103,6 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -129,7 +114,6 @@ const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,

View File

@@ -1,92 +1,81 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl/normal font-medium">
<div
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
>
<div class="flex w-full max-w-md flex-col items-start">
<div class="flex w-full flex-col gap-4">
<h1
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ t('auth.signup.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
>
<span class="text-primary-comfy-canvas/70">{{
t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
class="ml-1 cursor-pointer text-azure-600"
@click="navigateToLogin"
>{{ t('auth.signup.signIn') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<template v-if="!showEmailForm">
<p
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
class="mb-4 text-sm text-muted-foreground"
>
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
credits: freeTierCredits
})
: t('auth.login.freeTierDescriptionGeneric')
}}
</p>
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div v-if="!googleSsoBlockedReason" class="relative">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
</div>
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
<template v-if="!showEmailForm">
<Button
v-if="!googleSsoBlockedReason"
type="button"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGoogle"
>
<i class="pi pi-google text-base" />
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-charcoal-500"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
<i class="pi pi-github text-base" />
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
</template>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<Message v-if="userIsInChina" severity="warn" class="w-full">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm
v-else
:auth-error="authError"
@submit="signUpWithEmail"
/>
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="secondary"
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-sand-300/10"
@click="switchToSocialLogin"
>
{{
@@ -95,16 +84,17 @@
: t('auth.login.backToSocialLogin')
}}
</Button>
</div>
</template>
</template>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<p
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -112,16 +102,18 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</p>
<p class="mt-2 text-sm text-gray-600">
<p
class="mx-auto mt-2 mb-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>
@@ -159,7 +151,6 @@ const userIsInChina = ref(false)
const telemetry = useTelemetry()
const {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
@@ -197,7 +188,6 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
telemetry?.trackSignupOpened()
}
@@ -210,12 +200,22 @@ onMounted(async () => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-password-toggle-mask-icon) {
cursor: pointer;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -0,0 +1,106 @@
<template>
<div
class="relative flex size-full flex-col items-center overflow-hidden rounded-lg bg-primary-comfy-canvas/4 pt-10 pb-4"
>
<div
class="flex w-full flex-1 flex-col items-center justify-center gap-6 md:gap-8 lg:gap-10"
>
<div
class="relative aspect-5/4 w-full max-w-xs md:max-w-sm lg:max-w-md xl:max-w-lg"
>
<div class="absolute inset-0">
<div
class="absolute top-1/2 left-1/2 aspect-3/2 w-3/4 -translate-1/2"
>
<div
class="absolute inset-0 rounded-3xl border border-white/20 bg-white/10 p-3.5 shadow-2xl"
>
<div
class="size-full overflow-hidden rounded-xl bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${centerImage})` }"
/>
</div>
<div
class="absolute -top-1/20 -right-1/50 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--gemini-mono] size-3.5" />
</div>
<div
class="absolute -bottom-2/25 left-1/2 flex aspect-square w-10 -translate-x-1/2 items-center justify-center rounded-xl border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--grok] size-6" />
</div>
</div>
<div class="absolute top-3/20 left-1/20 aspect-5/3 w-7/20">
<div
class="absolute inset-0 overflow-hidden rounded-2xl border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${topLeft})` }"
/>
<div
class="absolute -top-1/10 right-1/10 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--bytedance-mono] size-3.5" />
</div>
</div>
<div
class="absolute bottom-3/20 left-1/10 aspect-4/3 w-1/4 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${bottomLeft})` }"
/>
<div
class="absolute right-2/25 bottom-1/10 aspect-4/3 w-3/10 overflow-hidden rounded-lg border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${bottomRight})` }"
/>
</div>
</div>
<div
class="relative flex w-full max-w-md flex-col items-center gap-1 text-center"
>
<p
class="m-0 font-inter text-base font-semibold text-primary-comfy-canvas"
>
{{ t('cloudHero.slides.cloud.title') }}
</p>
<p class="m-0 font-inter text-sm text-primary-comfy-canvas/70">
{{ t('cloudHero.slides.cloud.description') }}
</p>
</div>
</div>
<div
v-if="isCloud"
class="flex w-full items-center justify-center gap-3 pt-6"
>
<p class="m-0 hidden text-sm text-primary-comfy-canvas/90 md:block">
{{ t('cloudStart_wantToRun') }}
</p>
<a
href="https://www.comfy.org/download"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-8 items-center gap-2 rounded-lg border border-primary-comfy-canvas/20 bg-charcoal-500 p-2 text-xs font-medium text-primary-comfy-canvas no-underline hover:bg-charcoal-500/80"
>
<i class="pi pi-download text-xs text-primary-comfy-canvas/90" />
{{ t('cloudStart_download') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import bottomLeft from '@/platform/cloud/onboarding/assets/hero/bottom-left.jpg'
import bottomRight from '@/platform/cloud/onboarding/assets/hero/bottom-right.jpg'
import centerImage from '@/platform/cloud/onboarding/assets/hero/center-image.jpg'
import topLeft from '@/platform/cloud/onboarding/assets/hero/top-left.jpg'
import { isCloud } from '@/platform/distribution/types'
const { t } = useI18n()
</script>

View File

@@ -1,9 +0,0 @@
<template>
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
<img
src="/assets/images/comfy-cloud-logo.svg"
:alt="$t('subscription.comfyCloudLogo')"
class="h-3/4 max-h-10 w-auto"
/>
</div>
</template>

View File

@@ -67,7 +67,8 @@
<Button
v-else
type="submit"
class="mt-4 h-10 font-medium text-white"
variant="secondary"
class="relative mt-4 h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
:disabled="!$form.valid"
>
{{ t('auth.login.loginButton') }}
@@ -117,12 +118,22 @@ const onSubmit = (event: FormSubmitEvent) => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-password-toggle-mask-icon) {
cursor: pointer;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;

View File

@@ -1,81 +1,27 @@
<template>
<div class="flex">
<BaseViewTemplate dark class="flex-1">
<template #header>
<CloudLogo />
</template>
<slot />
<template #footer>
<CloudTemplateFooter />
</template>
</BaseViewTemplate>
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
<!-- Video Background -->
<video
class="absolute inset-0 size-full object-cover"
autoplay
muted
loop
playsinline
:poster="videoPoster"
>
<source :src="videoSrc" type="video/mp4" />
</video>
<div class="absolute inset-0 size-full bg-black/30"></div>
<!-- Optional Overlay for better visual -->
<div
class="absolute inset-0 flex items-center justify-center text-center text-white"
>
<div>
<h1 class="font-abcrom hero-title font-black uppercase italic">
{{ t('cloudStart_title') }}
</h1>
<p class="m-2 text-center text-xl text-white">
{{ t('cloudStart_desc') }}
</p>
<p class="m-0 text-center text-xl text-white">
{{ t('cloudStart_explain') }}
</p>
</div>
<div
class="flex h-svh w-screen bg-primary-comfy-ink font-sans text-primary-comfy-canvas"
>
<div class="relative flex flex-1 flex-col">
<div class="flex h-16 shrink-0 items-center px-6">
<i
class="icon-[comfy--comfy-logo] h-5 w-22 text-brand-yellow md:h-6 md:w-26"
/>
</div>
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
<div class="flex items-center justify-end">
<div class="flex items-center gap-3">
<p class="text-md text-white">
{{ t('cloudStart_wantToRun') }}
</p>
<Button
type="button"
class="h-10 bg-black font-bold text-white"
variant="secondary"
@click="handleDownloadClick"
>
{{ t('cloudStart_download') }}
</Button>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-auto">
<slot />
</div>
<CloudTemplateFooter />
</div>
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
<CloudHeroCarousel />
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const { t } = useI18n()
const handleDownloadClick = () => {
window.open('https://www.comfy.org/download', '_blank')
}
</script>
<style>
@import '../assets/css/fonts.css';

View File

@@ -1,5 +1,7 @@
<template>
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
<footer
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
>
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"

View File

@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
): string[] {
const names = new Set<string>()
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
addPathDetectionNames(names, asset.asset_hash, options)
addPathDetectionNames(names, asset.hash ?? asset.asset_hash, options)
addPathDetectionNames(names, asset.name, options)
const subfolder = asset.user_metadata?.subfolder

View File

@@ -501,7 +501,8 @@ function isAssetInstalled(
): boolean {
if (candidate.hash && candidate.hashType) {
const candidateHash = `${candidate.hashType}:${candidate.hash}`
if (assets.some((a) => a.asset_hash === candidateHash)) return true
if (assets.some((a) => (a.hash ?? a.asset_hash) === candidateHash))
return true
}
const normalizedName = normalizePath(candidate.name)

View File

@@ -12,6 +12,7 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -198,6 +199,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
}

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ChangeTracker } from '@/scripts/changeTracker'
import { installNodeAddedTelemetry } from './installNodeAddedTelemetry'
import { withNodeAddSource } from './nodeAddSource'
const trackNodeAdded = vi.fn()
vi.mock('..', () => ({
useTelemetry: () => ({ trackNodeAdded })
}))
function fakeGraph(): LGraph {
return { onNodeAdded: undefined } as unknown as LGraph
}
function fakeNode(type: string): LGraphNode {
return { type } as unknown as LGraphNode
}
describe('installNodeAddedTelemetry', () => {
beforeEach(() => {
trackNodeAdded.mockClear()
ChangeTracker.isLoadingGraph = false
})
afterEach(() => {
ChangeTracker.isLoadingGraph = false
})
it('fires trackNodeAdded with the current source on add', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
withNodeAddSource('sidebar_drag', () => {
graph.onNodeAdded?.(fakeNode('KSampler'))
})
expect(trackNodeAdded).toHaveBeenCalledExactlyOnceWith({
node_type: 'KSampler',
source: 'sidebar_drag'
})
})
it('defaults source to "unknown" outside withNodeAddSource', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
graph.onNodeAdded?.(fakeNode('CheckpointLoader'))
expect(trackNodeAdded).toHaveBeenCalledWith({
node_type: 'CheckpointLoader',
source: 'unknown'
})
})
it('skips telemetry during workflow load', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
ChangeTracker.isLoadingGraph = true
graph.onNodeAdded?.(fakeNode('VAEDecode'))
expect(trackNodeAdded).not.toHaveBeenCalled()
})
it('preserves an existing onNodeAdded subscriber', () => {
const graph = fakeGraph()
const previous = vi.fn()
graph.onNodeAdded = previous
installNodeAddedTelemetry(graph)
const node = fakeNode('LoadImage')
graph.onNodeAdded?.(node)
expect(previous).toHaveBeenCalledExactlyOnceWith(node)
expect(trackNodeAdded).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,23 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useTelemetry } from '..'
import { getCurrentNodeAddSource } from './nodeAddSource'
/**
* Wire `app:node_added` telemetry into a graph. Wraps any existing
* `onNodeAdded` callback so we don't displace other subscribers. Bulk
* additions during workflow load are skipped — `workflow_imported`
* already covers those.
*/
export function installNodeAddedTelemetry(graph: LGraph): void {
const previous = graph.onNodeAdded
graph.onNodeAdded = function (node) {
previous?.call(this, node)
if (ChangeTracker.isLoadingGraph) return
useTelemetry()?.trackNodeAdded({
node_type: node.type ?? 'unknown',
source: getCurrentNodeAddSource()
})
}
}

View File

@@ -0,0 +1,22 @@
import type { NodeAddSource } from '../types'
let currentSource: NodeAddSource = 'unknown'
export function getCurrentNodeAddSource(): NodeAddSource {
return currentSource
}
/**
* Set the node-add source for the duration of `fn`. Synchronous only —
* the source is read by the synchronous LGraph.onNodeAdded callback that
* fires inside `graph.add()`. Nesting restores the previous value on exit.
*/
export function withNodeAddSource<T>(source: NodeAddSource, fn: () => T): T {
const previous = currentSource
currentSource = source
try {
return fn()
} finally {
currentSource = previous
}
}

View File

@@ -10,6 +10,7 @@ import type {
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeAddedMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -316,6 +317,13 @@ export class GtmTelemetryProvider implements TelemetryProvider {
})
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.pushEvent('node_added', {
node_type: metadata.node_type,
source: metadata.source
})
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.pushEvent('template_filter', {
search_query: metadata.search_query,

View File

@@ -26,6 +26,7 @@ import type {
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeAddedMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
@@ -400,6 +401,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}

View File

@@ -7,20 +7,25 @@ const hoisted = vi.hoisted(() => {
const mockInit = vi.fn()
const mockIdentify = vi.fn()
const mockPeopleSet = vi.fn()
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
return {
mockCapture,
mockInit,
mockIdentify,
mockPeopleSet,
mockReset,
mockOnUserResolved,
mockOnUserLogout,
mockPosthog: {
default: {
init: mockInit,
capture: mockCapture,
identify: mockIdentify,
people: { set: mockPeopleSet }
people: { set: mockPeopleSet },
reset: mockReset
}
}
}
@@ -36,7 +41,8 @@ vi.mock('vue', async () => {
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved
onUserResolved: hoisted.mockOnUserResolved,
onUserLogout: hoisted.mockOnUserLogout
})
}))
@@ -130,7 +136,7 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
})
it('identifies user when onUserResolved fires', async () => {
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -166,6 +172,88 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('sets first_auth_at on new-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
})
it('does not set first_auth_at on returning-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: false,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: false,
user_id: 'user-123'
}
)
})
it('flushes queued first_auth_at before queued auth event', async () => {
const provider = createProvider()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.mockCapture.mock.invocationCallOrder[0]
)
})
it('queues events before initialization and flushes after', async () => {
const provider = createProvider()
@@ -236,6 +324,32 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('logout', () => {
it('registers onUserLogout watcher after init', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockOnUserLogout).toHaveBeenCalledOnce()
})
it('calls posthog.reset(true) when the watcher fires', async () => {
createProvider()
await vi.dynamicImportSettled()
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
callback()
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
})
it('does not register the watcher before init resolves', () => {
createProvider()
expect(hoisted.mockOnUserLogout).not.toHaveBeenCalled()
expect(hoisted.mockReset).not.toHaveBeenCalled()
})
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
const provider = createProvider()

View File

@@ -21,6 +21,7 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -84,6 +85,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private isEnabled = true
private posthog: PostHog | null = null
private eventQueue: QueuedEvent[] = []
private pendingFirstAuthAt = new Map<string, string>()
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
@@ -127,12 +129,25 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.isInitialized = true
this.flushEventQueue()
useCurrentUser().onUserResolved((user) => {
const currentUser = useCurrentUser()
currentUser.onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
this.setSubscriptionProperties()
}
})
// Anchored to session state rather than the logout button so it
// also covers token revocation, account deletion, and cross-tab
// sign-out (browserLocalPersistence). A logout that lands during
// the posthog-js dynamic-import window will not be observed here:
// events buffered pre-init are intentionally NOT queue-cleared on
// logout, which leaves a narrow race where a logout + different
// login both inside the import window would flush pre-init events
// under the new identity. Accepted as a known edge — re-adding
// pre-init logout handling would defeat the simplification.
currentUser.onUserLogout(() => {
this.posthog?.reset(true)
})
})
.catch((error) => {
console.error('Failed to load PostHog:', error)
@@ -151,6 +166,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private flushEventQueue(): void {
if (!this.isInitialized || !this.posthog) return
this.flushPendingFirstAuthAt()
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
@@ -161,6 +178,33 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
}
private flushPendingFirstAuthAt(): void {
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
this.setFirstAuthAt(userId, firstAuthAt)
}
this.pendingFirstAuthAt.clear()
}
private setFirstAuthAt(
userId: string,
firstAuthAt = new Date().toISOString()
): void {
if (!this.isEnabled) return
if (this.isInitialized && this.posthog) {
try {
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
} catch (error) {
console.error('Failed to set PostHog first auth timestamp:', error)
}
return
}
if (!this.pendingFirstAuthAt.has(userId)) {
this.pendingFirstAuthAt.set(userId, firstAuthAt)
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
@@ -241,6 +285,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
trackAuth(metadata: AuthMetadata): void {
if (metadata.is_new_user && metadata.user_id) {
this.setFirstAuthAt(metadata.user_id)
}
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
@@ -411,6 +458,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}

View File

@@ -232,6 +232,23 @@ export interface NodeSearchMetadata {
query: string
}
/**
* Node added metadata. `source` indicates how the user initiated the add.
* Bulk additions during workflow load are excluded — workflow_imported
* already covers that.
*/
export type NodeAddSource =
| 'sidebar_drag'
| 'search_modal'
| 'paste'
| 'programmatic'
| 'unknown'
export interface NodeAddedMetadata {
node_type: string
source: NodeAddSource
}
/**
* Node search result selection metadata
*/
@@ -437,6 +454,9 @@ export interface TelemetryProvider {
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
// Node-added-to-canvas analytics
trackNodeAdded?(metadata: NodeAddedMetadata): void
// Template filter tracking events
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
@@ -523,6 +543,7 @@ export const TelemetryEvents = {
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
NODE_ADDED: 'app:node_added',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',

View File

@@ -183,7 +183,7 @@ const createInputMappingWidget = (
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.map((asset) => asset.hash ?? asset.asset_hash)
.filter((hash): hash is string => !!hash)
)

View File

@@ -132,7 +132,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
// already carry their own URL-resolvable filename. Expanding them via
// resolveOutputAssetItems would synthesize sibling AssetItems without
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
if (asset.asset_hash) continue
if (asset.hash ?? asset.asset_hash) continue
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue

View File

@@ -25,6 +25,7 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
@@ -888,6 +889,7 @@ export class ComfyApp {
this.addAfterConfigureHandler(graph)
this.rootGraphInternal = graph
installNodeAddedTelemetry(graph)
this.canvas = new LGraphCanvas(canvasEl, graph)
// Make canvas states reactive so we can observe changes on them.
this.canvas.state = reactive(this.canvas.state)

View File

@@ -1,4 +1,3 @@
/** @knipIgnoreUnusedButUsedByCustomNodes */
export function getFromFlacBuffer(buffer: ArrayBuffer): Record<string, string> {
const dataView = new DataView(buffer)

View File

@@ -352,8 +352,9 @@ export const useAssetsStore = defineStore('assets', () => {
const inputAssetsByFilename = computed(() => {
const map = new Map<string, AssetItem>()
for (const asset of inputAssets.value) {
if (asset.asset_hash) {
map.set(asset.asset_hash, asset)
const hash = asset.hash ?? asset.asset_hash
if (hash) {
map.set(hash, asset)
}
}
return map

View File

@@ -4,6 +4,7 @@ import type {
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
/**
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
@@ -106,7 +107,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
node.pos[1] += graph_mouse[1] - topLeft[1]
// @ts-expect-error fixme ts strict error
graph.add(node, true)
withNodeAddSource('paste', () => graph.add(node, true))
nodes.push(node)
}