Compare commits
5 Commits
feat/globa
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3afe1d5eb5 | ||
|
|
436e7ee73f | ||
|
|
1c56640468 | ||
|
|
c25e88d366 | ||
|
|
cfe4e0f444 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,27 +134,6 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,7 +21,6 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
@@ -1,76 +0,0 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'closedBanners'
|
||||
const DISMISS_ATTR = 'data-banner-dismissed'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = {}
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: config.link
|
||||
? {
|
||||
href: config.link.href,
|
||||
title: t(config.link.titleKey, locale),
|
||||
target: config.link.target ? '_blank' : undefined,
|
||||
rel: resolveRel({ target: config.link.target ? '_blank' : '_self' }),
|
||||
buttonVariant: config.link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: routes.mcp
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@@ -82,7 +82,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
|
||||
@@ -26,10 +26,6 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2187,7 +2183,6 @@ const translations = {
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -2201,16 +2196,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
@@ -3979,12 +3974,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,9 +5,6 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import { createBannerVersion, evaluateBannerVisibility } from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -37,15 +34,6 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -136,18 +124,6 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script is:inline define:vars={{ bannerVersion }}>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem('closedBanners') || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute('data-banner-dismissed', '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -161,16 +137,8 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main>
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -12,6 +13,7 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -12,6 +13,7 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
@@ -94,12 +93,6 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
|
||||
// trivially unit-testable. Locale/section are plain strings on purpose.
|
||||
|
||||
export interface BannerVisibilityContext {
|
||||
currentLocale: string
|
||||
currentSection: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
export interface EvaluableBanner {
|
||||
isActive: boolean
|
||||
startsAt?: string
|
||||
endsAt?: string
|
||||
targetLocales?: readonly string[]
|
||||
targetSections?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server/build-time visibility gate. Returns false on the FIRST failing check,
|
||||
* in order: active flag → start window → end window → locale targeting →
|
||||
* section targeting. An empty/absent `targetLocales` means "all locales".
|
||||
*/
|
||||
export function evaluateBannerVisibility(
|
||||
banner: EvaluableBanner,
|
||||
ctx: BannerVisibilityContext
|
||||
): boolean {
|
||||
if (!banner.isActive) return false
|
||||
if (
|
||||
banner.startsAt &&
|
||||
ctx.now.getTime() < new Date(banner.startsAt).getTime()
|
||||
)
|
||||
return false
|
||||
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
|
||||
return false
|
||||
|
||||
const targetLocales = banner.targetLocales ?? []
|
||||
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
|
||||
return false
|
||||
|
||||
const targetSections = banner.targetSections ?? []
|
||||
if (!targetSections.includes(ctx.currentSection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface BannerLinkContent {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
rel?: string
|
||||
buttonVariant?: string
|
||||
}
|
||||
|
||||
export interface BannerVersionContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
link?: BannerLinkContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware version key. Editing the copy changes the hash, so a previously
|
||||
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
|
||||
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
|
||||
*/
|
||||
export function createBannerVersion(
|
||||
content: BannerVersionContent,
|
||||
locale: string
|
||||
): string {
|
||||
const contentString = JSON.stringify({
|
||||
locale,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
link: content.link
|
||||
})
|
||||
let hash = 0
|
||||
for (const char of contentString) {
|
||||
hash = Math.imul(hash, 31) + char.charCodeAt(0)
|
||||
}
|
||||
return `${content.id}_${locale}_v${hash}`
|
||||
}
|
||||
@@ -537,6 +537,7 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
@@ -6,10 +6,6 @@ import type {
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
const ROUTE_PATTERN_WORKFLOW_TEMPLATES = /\/api\/workflow_templates(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_INDEX = /\/templates\/index\.json(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_THUMBNAILS = /\/templates\/.*\.webp(?:\?.*)?$/
|
||||
|
||||
interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
@@ -45,6 +41,10 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
@@ -64,30 +64,29 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockCustomTemplates()
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockCustomTemplates(): Promise<void> {
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '{}',
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
await this.page.route(
|
||||
ROUTE_PATTERN_WORKFLOW_TEMPLATES,
|
||||
customTemplatesHandler
|
||||
)
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
@@ -99,8 +98,9 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
@@ -114,8 +114,12 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
@@ -125,6 +129,15 @@ export class TemplateHelper {
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
|
||||
@@ -7,6 +7,10 @@ export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
await use(createTemplateHelper(page))
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -28,12 +28,7 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -46,7 +46,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
@@ -691,8 +691,7 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -25,6 +25,8 @@ test.describe(
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
@@ -54,7 +56,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -99,7 +101,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -141,7 +143,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -182,7 +184,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -220,7 +222,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
@@ -234,8 +234,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,8 +14,7 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 94 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
return box
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,27 +84,29 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -293,12 +295,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
@@ -42,10 +42,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -53,7 +50,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,7 +64,11 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
@@ -5,7 +5,12 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
25
global.d.ts
vendored
@@ -41,29 +41,6 @@ interface GtagFunction {
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
type SyftDataTraits = Record<string, string | number | null | undefined>
|
||||
|
||||
interface SyftDataPendingFetch {
|
||||
args: unknown[]
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface SyftDataClient {
|
||||
identify(email: string, traits?: SyftDataTraits): void
|
||||
signup(email: string, traits?: SyftDataTraits): void
|
||||
track(event: string, traits?: SyftDataTraits): void
|
||||
page(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
fi?: SyftDataPendingFetch[]
|
||||
fetchID?: (...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
|
||||
interface SyftDisabledClient {
|
||||
enable: () => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
@@ -101,8 +78,6 @@ interface Window {
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
syft?: SyftDataClient | SyftDisabledClient
|
||||
syftc?: { sourceId?: string; enabled?: boolean }
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { cleanupSVG, importDirectorySync, runSVGO } from '@iconify/tools'
|
||||
import {
|
||||
cleanupSVG,
|
||||
importDirectorySync,
|
||||
isEmptyColor,
|
||||
parseColors,
|
||||
runSVGO
|
||||
} from '@iconify/tools'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const COMFY_ICON_PREFIX = 'comfy'
|
||||
@@ -7,6 +13,13 @@ const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
|
||||
|
||||
let cached
|
||||
|
||||
/**
|
||||
* Load the comfy icon folder as a normalized Iconify icon set.
|
||||
*
|
||||
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
|
||||
* runs internally so monotone hardcoded colors become `currentColor` and
|
||||
* outer-svg attributes like `fill="none"` survive the body extraction.
|
||||
*/
|
||||
export function loadComfyIconSet() {
|
||||
if (cached) return cached
|
||||
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
|
||||
@@ -19,6 +32,18 @@ export function loadComfyIconSet() {
|
||||
}
|
||||
try {
|
||||
cleanupSVG(svg)
|
||||
const palette = parseColors(svg)
|
||||
const colors = palette.colors.filter(
|
||||
(color) => typeof color === 'string' || !isEmptyColor(color)
|
||||
)
|
||||
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
|
||||
if (totalColors < 2) {
|
||||
parseColors(svg, {
|
||||
defaultColor: 'currentColor',
|
||||
callback: (_attr, colorStr, color) =>
|
||||
!color || isEmptyColor(color) ? colorStr : 'currentColor'
|
||||
})
|
||||
}
|
||||
runSVGO(svg)
|
||||
iconSet.fromSVG(name, svg)
|
||||
} catch {
|
||||
|
||||
23
packages/design-system/src/css/iconifyDynamicPlugin.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const options = {
|
||||
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
|
||||
scale: SCALE
|
||||
}
|
||||
|
||||
export default plugin(({ matchComponents }) => {
|
||||
matchComponents({
|
||||
icon: (icon) => {
|
||||
try {
|
||||
return getDynamicCSSRules(icon, options)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
|
||||
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
|
||||
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
|
||||
import { matchIconName } from '@iconify/utils/lib/icon/name'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const options = {
|
||||
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
|
||||
scale: SCALE
|
||||
}
|
||||
|
||||
function getModeCSSRules(icon: string, mode: 'mask' | 'background') {
|
||||
const nameParts = icon.split(/--|:/)
|
||||
if (nameParts.length !== 2) return {}
|
||||
|
||||
const [prefix, name] = nameParts
|
||||
if (!(prefix.match(matchIconName) && name.match(matchIconName))) return {}
|
||||
|
||||
const iconSet =
|
||||
prefix === COMFY_ICON_PREFIX ? loadComfyIconSet() : loadIconSet(prefix)
|
||||
if (!iconSet) return {}
|
||||
|
||||
const generated = getIconsCSSData(iconSet, [name], {
|
||||
iconSelector: '.icon',
|
||||
mode
|
||||
})
|
||||
if (generated.css.length !== 1) return {}
|
||||
|
||||
const size = { width: `${SCALE}em`, height: `${SCALE}em` }
|
||||
return { ...generated.common?.rules, ...size, ...generated.css[0].rules }
|
||||
}
|
||||
|
||||
export default plugin(({ matchComponents }) => {
|
||||
matchComponents({
|
||||
icon: (icon) => {
|
||||
try {
|
||||
return getDynamicCSSRules(icon, options)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
'icon-img': (icon) => getModeCSSRules(icon, 'background'),
|
||||
'icon-mask': (icon) => getModeCSSRules(icon, 'mask')
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@plugin "./iconifyDynamicPlugin.ts";
|
||||
@plugin "./iconifyDynamicPlugin.js";
|
||||
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,claude,comfy-logo,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,claude,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}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 1014 B |
@@ -164,34 +164,6 @@ packages/design-system/src/icons/
|
||||
|
||||
No imports needed - icons are auto-discovered!
|
||||
|
||||
## Render Mode: Mask vs Image
|
||||
|
||||
The default render mode is decided by the SVG's own colors:
|
||||
|
||||
- **Mask** — SVG with `currentColor`. The shape is displayed as a background
|
||||
mask, so it adapts to the theme via `text-*` classes.
|
||||
- **Image** — the SVG uses concrete colors (e.g. `#d97757`). It is embedded
|
||||
as an image, preserving its own colors; `text-*` has no effect. Use this for
|
||||
brand logos that must keep their palette.
|
||||
|
||||
```
|
||||
workflow.svg fill="currentColor" -> mask (themeable)
|
||||
claude.svg fill="#d97757" -> image (brand color preserved)
|
||||
```
|
||||
|
||||
### Forcing a mode at usage time
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i class="icon-[comfy--openai]" />
|
||||
<!-- default from SVG colors -->
|
||||
<i class="icon-img-[comfy--openai]" />
|
||||
<!-- force image, own colors -->
|
||||
<i class="icon-mask-[comfy--luma]" />
|
||||
<!-- force themeable mask -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Naming Conventions
|
||||
|
||||