4 Commits

Author SHA1 Message Date
  DrJKL 538c5a54b3 Move two supported linters into oxlint 2 days ago
  Alexander Brown 8d7dd9ed67
Component: Button migration 1: TextButton (#7537) 2 days ago
  AustinMroz ab76d02823
Fix doubled control application (#7550) 2 days ago
  Simula_r fa37112caf
feat(cloud): yearly pricing (#7572) 2 days ago
39 changed files with 708 additions and 1131 deletions
Split View
  1. +7
    -0
      .oxlintrc.json
  2. +0
    -7
      eslint.config.ts
  3. +1
    -0
      package.json
  4. +1
    -1
      packages/design-system/src/css/style.css
  5. +20
    -0
      pnpm-lock.yaml
  6. +1
    -0
      pnpm-workspace.yaml
  7. +0
    -91
      src/components/button/TextButton.stories.ts
  8. +0
    -54
      src/components/button/TextButton.vue
  9. +9
    -12
      src/components/dialog/confirm/ConfirmFooter.vue
  10. +7
    -13
      src/components/dialog/content/MissingNodesFooter.vue
  11. +17
    -16
      src/components/input/MultiSelect.vue
  12. +8
    -6
      src/components/queue/QueueOverlayActive.vue
  13. +10
    -13
      src/components/queue/dialogs/QueueClearHistoryDialog.vue
  14. +8
    -9
      src/components/queue/job/JobFiltersBar.vue
  15. +7
    -7
      src/components/queue/job/QueueJobItem.vue
  16. +14
    -9
      src/components/sidebar/tabs/AssetsSidebarTab.vue
  17. +68
    -0
      src/components/ui/button/Button.stories.ts
  18. +28
    -0
      src/components/ui/button/Button.vue
  19. +49
    -0
      src/components/ui/button/button.variants.ts
  20. +2
    -0
      src/extensions/core/widgetInputs.ts
  21. +27
    -9
      src/locales/en/main.json
  22. +18
    -16
      src/platform/assets/components/UploadModelFooter.vue
  23. +7
    -13
      src/platform/assets/components/UploadModelUpgradeModalFooter.vue
  24. +256
    -146
      src/platform/cloud/subscription/components/PricingTable.vue
  25. +20
    -32
      src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue
  26. +4
    -3
      src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
  27. +14
    -31
      src/renderer/extensions/vueNodes/widgets/components/ValueControlPopover.vue
  28. +19
    -7
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue
  29. +0
    -59
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue
  30. +12
    -1
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
  31. +4
    -1
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
  32. +60
    -0
      src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue
  33. +0
    -111
      src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts
  34. +0
    -59
      src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts
  35. +0
    -3
      src/scripts/app.ts
  36. +7
    -0
      src/types/simplifiedWidget.ts
  37. +0
    -238
      tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts
  38. +0
    -163
      tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts
  39. +3
    -1
      vite.config.mts

+ 7
- 0
.oxlintrc.json View File

@@ -94,6 +94,13 @@
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-import-type-side-effects": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "always"
}
],
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",


+ 0
- 7
eslint.config.ts View File

@@ -126,13 +126,6 @@ export default defineConfig([
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',


+ 1
- 0
package.json View File

@@ -161,6 +161,7 @@
"algoliasearch": "catalog:",
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",


+ 1
- 1
packages/design-system/src/css/style.css View File

@@ -235,7 +235,7 @@
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);


+ 20
- 0
pnpm-lock.yaml View File

@@ -138,6 +138,9 @@ catalogs:
cross-env:
specifier: ^10.1.0
version: 10.1.0
cva:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4
dotenv:
specifier: ^16.4.5
version: 16.6.1
@@ -419,6 +422,9 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
cva:
specifier: 'catalog:'
version: 1.0.0-beta.4(typescript@5.9.2)
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -4669,6 +4675,14 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}

cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
typescript: '>= 4.5.5'
peerDependenciesMeta:
typescript:
optional: true

data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -12808,6 +12822,12 @@ snapshots:

csstype@3.2.3: {}

cva@1.0.0-beta.4(typescript@5.9.2):
dependencies:
clsx: 2.1.1
optionalDependencies:
typescript: 5.9.2

data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0


+ 1
- 0
pnpm-workspace.yaml View File

@@ -47,6 +47,7 @@ catalog:
algoliasearch: ^5.21.0
axios: ^1.8.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8


+ 0
- 91
src/components/button/TextButton.stories.ts View File

@@ -1,91 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import TextButton from './TextButton.vue'

const meta: Meta<typeof TextButton> = {
title: 'Components/Button/TextButton',
component: TextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Click me'
},
size: {
control: { type: 'select' },
options: ['sm', 'md'],
defaultValue: 'md'
},
border: {
control: 'boolean',
description: 'Toggle border attribute'
},
disabled: {
control: 'boolean',
description: 'Toggle disable status'
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent'],
defaultValue: 'primary'
},
onClick: { action: 'clicked' }
}
}

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
args: {
label: 'Primary Button',
type: 'primary',
size: 'md'
}
}

export const Secondary: Story = {
args: {
label: 'Secondary Button',
type: 'secondary',
size: 'md'
}
}

export const Transparent: Story = {
args: {
label: 'Transparent Button',
type: 'transparent',
size: 'md'
}
}

export const Small: Story = {
args: {
label: 'Small Button',
type: 'primary',
size: 'sm'
}
}

export const AllVariants: Story = {
render: () => ({
components: { TextButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
</div>
</div>
`
})
}

+ 0
- 54
src/components/button/TextButton.vue View File

@@ -1,54 +0,0 @@
<template>
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<span>{{ label }}</span>
</Button>
</template>

<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'

import type { BaseButtonProps } from '@/types/buttonTypes'
import {
getBaseButtonClasses,
getBorderButtonTypeClasses,
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'

interface TextButtonProps extends BaseButtonProps {
label: string
onClick: () => void
}

defineOptions({
inheritAttrs: false
})

const {
size = 'md',
type = 'primary',
border = false,
disabled = false,
class: className,
label,
onClick
} = defineProps<TextButtonProps>()

const buttonStyle = computed(() => {
const baseClasses = getBaseButtonClasses()
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)

return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

+ 9
- 12
src/components/dialog/confirm/ConfirmFooter.vue View File

@@ -1,19 +1,16 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
{{ cancelTextX }}
</Button>
<Button
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
variant="textonly"
:class="confirmClass"
@click="$emit('confirm')"
/>
>
{{ confirmTextX }}
</Button>
</section>
</template>
<script setup lang="ts">
@@ -21,7 +18,7 @@ import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'

import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'

const { t } = useI18n()



+ 7
- 13
src/components/dialog/content/MissingNodesFooter.vue View File

@@ -18,22 +18,16 @@
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<TextButton
:label="$t('missingNodes.cloud.gotIt')"
type="secondary"
size="md"
@click="handleGotItClick"
/>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>

<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<TextButton
:label="$t('g.openManager')"
type="transparent"
size="sm"
@click="openManager"
/>
<Button variant="textonly" size="sm" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
@@ -57,7 +51,7 @@ import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'


+ 17
- 16
src/components/input/MultiSelect.vue View File

@@ -62,7 +62,7 @@
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex gap-2 items-center h-10 px-2 rounded-lg',
'flex gap-2 items-center h-10 px-2 rounded-lg cursor-pointer',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
@@ -112,14 +112,14 @@
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
<Button
v-if="showClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm text-text-primary"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
/>
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default"></div>
</div>
@@ -145,9 +145,13 @@

<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2" :style="popoverStyle">
<div
role="button"
class="flex items-center gap-2 cursor-pointer"
:style="popoverStyle"
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
slotProps.selected
? 'bg-primary-background'
@@ -159,11 +163,9 @@
class="text-bold icon-[lucide--check] text-xs text-white"
/>
</div>
<Button
class="border-none bg-transparent text-left outline-none"
unstyled
>{{ slotProps.option.name }}</Button
>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
@@ -172,17 +174,16 @@
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import Button from 'primevue/button'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'

import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'

import TextButton from '../button/TextButton.vue'
import type { SelectOption } from './types'

type Option = SelectOption


+ 8
- 6
src/components/queue/QueueOverlayActive.vue View File

@@ -80,12 +80,14 @@
</div>
</div>

<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
<Button
class="min-w-30 flex-1 px-2 py-0"
variant="secondary"
size="sm"
@click="$emit('viewAllJobs')"
/>
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</Button>
</div>
</div>
</template>
@@ -95,7 +97,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

defineProps<{


+ 10
- 13
src/components/queue/dialogs/QueueClearHistoryDialog.vue View File

@@ -31,20 +31,17 @@
</div>

<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
@click="onCancel"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
<div class="flex items-center gap-4 leading-none">
<Button variant="muted-textonly" size="lg" @click="onCancel">
{{ t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="isClearing"
@click="onConfirm"
/>
>{{ t('g.clear') }}</Button
>
</div>
</footer>
</section>
@@ -55,7 +52,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'


+ 8
- 9
src/components/queue/job/JobFiltersBar.vue View File

@@ -2,17 +2,16 @@
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<TextButton
<Button
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
]"
:label="tabLabel(tab)"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
class="px-3"
@click="$emit('update:selectedJobTab', tab)"
/>
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
@@ -155,7 +154,7 @@ import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'


+ 7
- 7
src/components/queue/job/QueueJobItem.vue View File

@@ -154,14 +154,14 @@
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<TextButton
<Button
v-else-if="props.state === 'completed'"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')"
class="transform bg-modal-card-button-surface px-2 py-0 transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
variant="textonly"
size="sm"
@click.stop="emit('view')"
/>
>{{ t('menuLabels.View') }}</Button
>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
@@ -204,9 +204,9 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'


+ 14
- 9
src/components/sidebar/tabs/AssetsSidebarTab.vue View File

@@ -112,18 +112,20 @@
>
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<TextButton
:label="
<Button
variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
"
type="secondary"
:class="isCompact ? 'text-left' : ''"
@click="handleDeselectAll"
/>
}}
</Button>
</div>
</div>
<div class="flex gap-2 pr-4">
@@ -179,10 +181,10 @@ import { Divider } from 'primevue'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
@@ -190,7 +192,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
@@ -203,6 +205,9 @@ import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'

const { t } = useI18n()

const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)


+ 68
- 0
src/components/ui/button/Button.stories.ts View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import Button from './Button.vue'
import { FOR_STORIES } from '@/components/ui/button/button.variants'

const { variants, sizes } = FOR_STORIES
const meta: Meta<typeof Button> = {
title: 'Components/Button/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: sizes,
defaultValue: 'md'
},
variant: {
control: { type: 'select' },
options: variants,
defaultValue: 'primary'
},
as: { defaultValue: 'button' },
asChild: { defaultValue: false },
default: {
defaultValue: 'Button'
}
},
args: {
variant: 'secondary',
size: 'md',
default: 'Button'
}
}

export default meta
type Story = StoryObj<typeof meta>

export const SingleButton: Story = {
args: {
variant: 'primary',
size: 'lg'
}
}

function generateVariants() {
const variantButtons: string[] = []
for (const variant of variants) {
for (const size of sizes) {
variantButtons.push(
`<Button variant="${variant}" size="${size}">${size === 'icon' ? `<i class="icon-[lucide--settings]" />` : variant}</Button>`
)
}
}
return variantButtons
}

// Note: Keep the number of columns here aligned with the number of sizes above.
export const AllVariants: Story = {
render: () => ({
components: { Button },
template: `
<div class="grid grid-cols-4 gap-4 place-items-center-safe">
${generateVariants().join('\n')}
</div>
`
})
}

+ 28
- 0
src/components/ui/button/Button.vue View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

import { cn } from '@/utils/tailwindUtil'

import type { ButtonVariants } from './button.variants'
import { buttonVariants } from './button.variants'

interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}

const { as = 'button', class: customClass = '' } = defineProps<Props>()
</script>

<template>
<Primitive
:as
:as-child
:class="cn(buttonVariants({ variant, size }), customClass)"
>
<slot />
</Primitive>
</template>

+ 49
- 0
src/components/ui/button/button.variants.ts View File

@@ -0,0 +1,49 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'

export const buttonVariants = cva({
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:
'bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover',
primary:
'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
inverted:
'bg-base-foreground text-base-background hover:bg-base-foreground/80',
destructive:
'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
textonly:
'text-base-foreground bg-transparent hover:bg-secondary-background-hover',
'muted-textonly':
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-9'
}
},

defaultVariants: {
variant: 'secondary',
size: 'md'
}
})

export type ButtonVariants = VariantProps<typeof buttonVariants>

const variants = [
'secondary',
'primary',
'inverted',
'destructive',
'textonly',
'muted-textonly'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon'] as const satisfies Array<
ButtonVariants['size']
>

export const FOR_STORIES = { variants, sizes } as const

+ 2
- 0
src/extensions/core/widgetInputs.ts View File

@@ -257,6 +257,8 @@ export class PrimitiveNode extends LGraphNode {
undefined,
inputData
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]

let filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter


+ 27
- 9
src/locales/en/main.json View File

@@ -1907,7 +1907,7 @@
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"usdPerMonth": "USD / mo",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
@@ -1929,16 +1929,21 @@
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"billedMonthly": "Billed monthly",
"billedAnnually": "{total} Billed annually",
"monthly": "Monthly",
"yearly": "Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"founder": {
"name": "Founder's Edition",
"price": "20.00",
"price": "20",
"benefits": {
"monthlyCredits": "5,460",
"monthlyCreditsLabel": "monthly credits",
@@ -1951,7 +1956,11 @@
},
"standard": {
"name": "Standard",
"price": "20.00",
"price": {
"monthly": "20",
"yearly": "16",
"annualTotal": "$192"
},
"benefits": {
"monthlyCredits": "4,200",
"monthlyCreditsLabel": "monthly credits",
@@ -1965,7 +1974,12 @@
},
"creator": {
"name": "Creator",
"price": "35.00",
"price": {
"monthly": "35",
"yearly": "28",
"annualTotal": "$336"
},

"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
@@ -1979,7 +1993,11 @@
},
"pro": {
"name": "Pro",
"price": "100.00",
"price": {
"monthly": "100",
"yearly": "80",
"annualTotal": "$960"
},
"benefits": {
"monthlyCredits": "21,100",
"monthlyCreditsLabel": "monthly credits",
@@ -2004,7 +2022,7 @@
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"viewEnterprise": "View enterprise",
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",
@@ -2059,7 +2077,7 @@
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"valueControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
@@ -2072,9 +2090,9 @@
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"incrementDesc": "Adds 1 to value or selects the next option",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"decrementDesc": "Subtracts 1 from value or selects the previous option",
"fixed": "Fixed Value",
"fixedDesc": "Leaves value unchanged",
"editSettings": "Edit control settings"


+ 18
- 16
src/platform/assets/components/UploadModelFooter.vue View File

@@ -13,24 +13,26 @@
<i class="icon-[lucide--circle-question-mark]" />
</template>
</IconTextButton>
<TextButton
<Button
v-if="currentStep === 1"
:label="$t('g.cancel')"
type="transparent"
size="md"
variant="muted-textonly"
size="lg"
data-attr="upload-model-step1-cancel-button"
:disabled="isFetchingMetadata || isUploading"
@click="emit('close')"
/>
<TextButton
>
{{ $t('g.cancel') }}
</Button>
<Button
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="transparent"
size="md"
variant="muted-textonly"
size="lg"
:data-attr="`upload-model-step${currentStep}-back-button`"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
>
{{ $t('g.back') }}
</Button>
<span v-else />

<IconTextButton
@@ -65,14 +67,14 @@
/>
</template>
</IconTextButton>
<TextButton
<Button
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="secondary"
size="md"
variant="secondary"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
/>
>
{{ $t('assetBrowser.finish') }}
</Button>
<VideoHelpDialog
v-model="showVideoHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -85,7 +87,7 @@
import { ref } from 'vue'

import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'

const showVideoHelp = ref(false)


+ 7
- 13
src/platform/assets/components/UploadModelUpgradeModalFooter.vue View File

@@ -9,23 +9,17 @@
<i class="icon-[lucide--external-link]" />
<span>{{ $t('g.learnMore') }}</span>
</a>
<TextButton
:label="$t('g.close')"
type="transparent"
size="md"
@click="emit('close')"
/>
<TextButton
:label="$t('subscription.required.subscribe')"
type="secondary"
size="md"
@click="emit('subscribe')"
/>
<Button variant="textonly" @click="emit('close')">{{
$t('g.close')
}}</Button>
<Button variant="secondary" @click="emit('subscribe')">
{{ $t('subscription.required.subscribe') }}
</Button>
</div>
</template>

<script setup lang="ts">
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'

const emit = defineEmits<{
close: []


+ 256
- 146
src/platform/cloud/subscription/components/PricingTable.vue View File

@@ -1,171 +1,246 @@
<template>
<div class="flex flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<div class="flex flex-col gap-6 p-8">
<div class="flex flex-row items-center gap-2">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground h-[13px] leading-[13px]"
>
{{ t('subscription.mostPopular') }}
<div class="flex flex-col gap-8">
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
:options="billingCycleOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="{
root: {
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
'w-36 h-8 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
context.active
? 'bg-base-foreground text-base-background'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
]
}),
label: { class: 'flex items-center gap-2 ' }
}
}"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="bg-primary-background text-white text-[11px] px-1 py-0.5 rounded-full flex items-center font-bold"
>
-20%
</div>
</div>
</div>
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
${{ tier.price }}
</span>
<span
class="font-inter text-base font-normal leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
</div>

<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-muted-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
</template>
</SelectButton>
</div>
<div class="flex flex-col xl:flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
:class="
cn(
'flex-1 flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)]',
tier.isPopular ? 'border-muted-foreground' : ''
)
"
>
<div class="p-8 pb-0 flex flex-col gap-8">
<div class="flex flex-row items-center gap-2 justify-between">
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-base-foreground px-1.5 text-[11px] font-bold uppercase text-base-background h-5 tracking-tight flex items-center"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
<span
v-show="currentBillingCycle === 'yearly'"
class="line-through text-2xl text-muted-foreground"
>
${{ tier.price.monthly }}
</span>
${{ getPrice(tier) }}
</span>
<span
class="font-inter text-xl leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{
currentBillingCycle === 'yearly'
? t('subscription.billedAnnually', {
total: tier.price.annualTotal
})
: t('subscription.billedMonthly')
}}
</span>
</div>
</div>
</div>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-col gap-4 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
</span>
</div>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>

<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>

<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateLabel') }}
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i
class="pi pi-question-circle text-xs text-muted-foreground"
/>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-foreground" />
</div>

<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-foreground">
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
@click="togglePopover"
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ t('subscription.videoEstimateHelp') }}
{{ tier.videoEstimate }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.videoEstimate }}
</span>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
:class="
cn(
'h-10 w-full',
tier.key === 'creator'
? 'bg-base-foreground border-transparent hover:bg-inverted-background-hover'
: 'bg-secondary-background border-transparent hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
)
"
:pt="{
label: {
class: getButtonTextClass(tier)
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</div>

<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
class="h-10 w-full"
:pt="{
label: {
class: getButtonTextClass(tier)
}
}"
@click="() => handleSubscribe(tier.key)"
/>
<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
>
{{ t('subscription.videoEstimateTryTemplate') }}
</a>
</div>
</div>
</Popover>
</div>

<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
>
{{ t('subscription.videoEstimateTryTemplate') }}
</a>
</div>
</Popover>
</template>

<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, ref } from 'vue'

import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -182,12 +257,25 @@ import type { components } from '@/types/comfyRegistryTypes'

type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
type CheckoutTier = TierKey | `${TierKey}-yearly`

type BillingCycle = 'monthly' | 'yearly'

const getCheckoutTier = (
tierKey: TierKey,
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)

interface BillingCycleOption {
label: string
value: BillingCycle
}

interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
price: Record<BillingCycle, string> & { annualTotal: string }
credits: string
maxDuration: string
customLoRAs: boolean
@@ -195,6 +283,11 @@ interface PricingTierConfig {
isPopular?: boolean
}

const billingCycleOptions: BillingCycleOption[] = [
{ label: t('subscription.yearly'), value: 'yearly' },
{ label: t('subscription.monthly'), value: 'monthly' }
]

const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
@@ -207,7 +300,11 @@ const tiers: PricingTierConfig[] = [
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
price: {
monthly: t('subscription.tiers.standard.price.monthly'),
yearly: t('subscription.tiers.standard.price.yearly'),
annualTotal: t('subscription.tiers.standard.price.annualTotal')
},
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
@@ -218,7 +315,11 @@ const tiers: PricingTierConfig[] = [
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
price: {
monthly: t('subscription.tiers.creator.price.monthly'),
yearly: t('subscription.tiers.creator.price.yearly'),
annualTotal: t('subscription.tiers.creator.price.annualTotal')
},
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
@@ -229,7 +330,11 @@ const tiers: PricingTierConfig[] = [
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
price: {
monthly: t('subscription.tiers.pro.price.monthly'),
yearly: t('subscription.tiers.pro.price.yearly'),
annualTotal: t('subscription.tiers.pro.price.annualTotal')
},
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
@@ -246,6 +351,7 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')

const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
@@ -274,17 +380,21 @@ const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>

const getButtonTextClass = (tier: PricingTierConfig): string =>
tier.key === 'creator'
? 'font-inter text-sm font-bold leading-normal text-white'
? 'font-inter text-sm font-bold leading-normal text-base-background'
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'

const getPrice = (tier: PricingTierConfig): string =>
tier.price[currentBillingCycle.value]

const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }


+ 20
- 32
src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue View File

@@ -1,45 +1,33 @@
<template>
<div
v-if="showCustomPricingTable"
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
class="relative flex flex-col p-4 pt-8 md:p-16 !overflow-y-auto h-full gap-8"
>
<div
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
<div
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
>
{{ $t('subscription.required.title') }}
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
<div class="text-3xl font-semibold leading-tight md:text-4xl">
{{ $t('subscription.description') }}
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="handleClose"
/>
<Button
:pt="{
icon: { class: 'text-xl' }
}"
icon="pi pi-times"
text
rounded
class="shrink-0 text-text-secondary hover:bg-white/10 absolute right-2.5 top-2.5"
:aria-label="$t('g.close')"
@click="handleClose"
/>
<div class="text-center">
<h2 class="text-xl lg:text-2xl text-muted-foreground m-0">
{{ $t('subscription.description') }}
</h2>
</div>

<PricingTable class="flex-1" />

<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
<div class="flex flex-col items-center gap-2">
<p class="text-sm text-text-secondary m-0">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<Button
:label="$t('subscription.contactUs')"
text
@@ -95,7 +83,7 @@
<div>
<div class="flex flex-col gap-6">
<div class="inline-flex items-center gap-2">
<div class="text-sm text-muted text-text-primary">
<div class="text-sm text-text-primary">
{{ $t('subscription.required.title') }}
</div>
<CloudBadge


+ 4
- 3
src/platform/cloud/subscription/composables/useSubscriptionDialog.ts View File

@@ -23,13 +23,14 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: min(1200px, 95vw); max-height: 90vh;',
style: 'width: min(1328px, 95vw); max-height: 90vh;',
pt: {
root: {
class: '!rounded-[32px] overflow-visible'
class: 'rounded-2xl bg-transparent'
},
content: {
class: '!p-0 bg-transparent'
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}


src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue → src/renderer/extensions/vueNodes/widgets/components/ValueControlPopover.vue View File

@@ -4,12 +4,11 @@ import RadioButton from 'primevue/radiobutton'
import { computed, ref } from 'vue'

import { useSettingStore } from '@/platform/settings/settingStore'

import { NumberControlMode } from '../composables/useStepperControl'
import type { ControlOptions } from '@/types/simplifiedWidget'

type ControlOption = {
description: string
mode: NumberControlMode
mode: ControlOptions
icon?: string
text?: string
title: string
@@ -23,39 +22,27 @@ const toggle = (event: Event) => {
}
defineExpose({ toggle })

const ENABLE_LINK_TO_GLOBAL = false

const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: NumberControlMode.FIXED,
mode: 'fixed',
icon: 'icon-[lucide--pencil-off]',
title: 'fixed',
description: 'fixedDesc'
},
{
mode: NumberControlMode.INCREMENT,
mode: 'increment',
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: NumberControlMode.DECREMENT,
mode: 'decrement',
text: '-1',
title: 'decrement',
description: 'decrementDesc'
},
{
mode: NumberControlMode.RANDOMIZE,
mode: 'randomize',
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
@@ -66,7 +53,7 @@ const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)

const controlMode = defineModel<NumberControlMode>()
const controlMode = defineModel<ControlOptions>()
</script>

<template>
@@ -76,15 +63,15 @@ const controlMode = defineModel<NumberControlMode>()
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.header.prefix') }}
{{ $t('widgets.valueControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.header.before')
: $t('widgets.numberControl.header.after')
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
}}
</span>
{{ $t('widgets.numberControl.header.postfix') }}
{{ $t('widgets.valueControl.header.postfix') }}
</div>

<div class="space-y-2">
@@ -114,18 +101,14 @@ const controlMode = defineModel<NumberControlMode>()
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
{{ $t(`widgets.valueControl.${option.description}`) }}
</div>
</div>
</div>

+ 19
- 7
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'

import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
import WidgetWithControl from './WidgetWithControl.vue'

const props = defineProps<{
widget: SimplifiedWidget<number>
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
</script>

<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
v-model="modelValue"
:widget="widget as SimplifiedControlWidget<number>"
:component="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
/>
<component
:is="
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-else
v-model="modelValue"
:widget="widget"
v-bind="$attrs"


+ 0
- 59
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref, watch } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'

import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'

const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)

const props = defineProps<{
widget: SimplifiedWidget<number>
}>()

const modelValue = defineModel<number>({ default: 0 })
const popover = ref()

const handleControlChange = (newValue: number) => {
modelValue.value = newValue
}

const { controlMode, controlButtonIcon } = useStepperControl(
modelValue,
{
...props.widget.options,
onChange: handleControlChange
},
props.widget.controlWidget!.value
)

watch(controlMode, props.widget.controlWidget!.update)

const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>

<template>
<div class="relative grid grid-cols-subgrid">
<WidgetInputNumberInput
v-model="modelValue"
:widget
class="grid grid-cols-subgrid col-span-2"
>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover ref="popover" v-model="controlMode" />
</div>
</template>

+ 12
- 1
src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue View File

@@ -9,6 +9,11 @@
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
<WidgetWithControl
v-else-if="widget.controlWidget"
:component="WidgetSelectDefault"
:widget="widget as StringControlWidget"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>

@@ -20,13 +25,19 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'

type StringControlWidget = SimplifiedControlWidget<string | undefined>

const props = defineProps<{
widget: SimplifiedWidget<string | undefined>
nodeType?: string


+ 4
- 1
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue View File

@@ -13,11 +13,14 @@
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: 'truncate min-w-[4ch]',
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
/>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>
</template>



+ 60
- 0
src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue View File

@@ -0,0 +1,60 @@
<script setup lang="ts" generic="T extends WidgetValue">
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import type { Component } from 'vue'

import type {
SimplifiedControlWidget,
WidgetValue
} from '@/types/simplifiedWidget'

const ValueControlPopover = defineAsyncComponent(
() => import('./ValueControlPopover.vue')
)

const props = defineProps<{
widget: SimplifiedControlWidget<T>
component: Component
}>()

const modelValue = defineModel<T>()

const popover = ref()

const controlModel = ref(props.widget.controlWidget.value)

const controlButtonIcon = computed(() => {
switch (controlModel.value) {
case 'increment':
return 'pi pi-plus'
case 'decrement':
return 'pi pi-minus'
case 'fixed':
return 'icon-[lucide--pencil-off]'
default:
return 'icon-[lucide--shuffle]'
}
})

watch(controlModel, props.widget.controlWidget.update)

const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>

<template>
<div class="relative grid grid-cols-subgrid">
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>
</component>
<ValueControlPopover ref="popover" v-model="controlModel" />
</div>
</template>

+ 0
- 111
src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts View File

@@ -1,111 +0,0 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'

import type { ControlOptions } from '@/types/simplifiedWidget'

import { numberControlRegistry } from '../services/NumberControlRegistry'

export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}

interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}

function convertToEnum(str?: ControlOptions): NumberControlMode {
switch (str) {
case 'fixed':
return NumberControlMode.FIXED
case 'increment':
return NumberControlMode.INCREMENT
case 'decrement':
return NumberControlMode.DECREMENT
case 'randomize':
return NumberControlMode.RANDOMIZE
}
return NumberControlMode.RANDOMIZE
}

function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.FIXED:
return 'icon-[lucide--pencil-off]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}

export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions,
defaultValue?: ControlOptions
) {
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
const controlId = Symbol('numberControl')

const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
const safeMax = Math.min(2 ** 50, max)
const safeMin = Math.max(-(2 ** 50), min)
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step

let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(safeMax, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(safeMin, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
break
default:
return
}

if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}

// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})

// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
const controlButtonIcon = useControlButtonIcon(controlMode)

return {
applyControl,
controlButtonIcon,
controlMode
}
}

+ 0
- 59
src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts View File

@@ -1,59 +0,0 @@
import { useSettingStore } from '@/platform/settings/settingStore'

/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()

/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}

/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}

/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}

/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}

/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}

// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()

/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

+ 0
- 3
src/scripts/app.ts View File

@@ -31,7 +31,6 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1359,7 +1358,6 @@ export class ComfyApp {
forEachNode(this.rootGraph, (node) => {
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
})
executeNumberControls('before')

const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
@@ -1404,7 +1402,6 @@ export class ComfyApp {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
executeWidgetsCallback(queuedNodes, 'afterQueued')
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()
}


+ 7
- 0
src/types/simplifiedWidget.ts View File

@@ -72,3 +72,10 @@ export interface SimplifiedWidget<

controlWidget?: SafeControlWidget
}

export interface SimplifiedControlWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
> extends SimplifiedWidget<T, O> {
controlWidget: SafeControlWidget
}

+ 0
- 238
tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts View File

@@ -1,238 +0,0 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'

import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'

// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)

describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})

describe('initialization', () => {
it('should initialize with RANDOMIZED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode } = useStepperControl(modelValue, options)

expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
})

it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)

expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
expect(typeof applyControl).toBe('function')
})
})

describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED

applyControl()
expect(modelValue.value).toBe(100)
})

it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(105)
})

it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT

applyControl()
expect(modelValue.value).toBe(95)
})

it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})

it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT

applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})

it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE

applyControl()

// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)

// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
})

describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})

it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE

applyControl()

// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})

describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()

expect(onChange).toHaveBeenCalledWith(101)
})

it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()

expect(modelValue.value).toBe(101)
})

it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED

applyControl()

expect(onChange).not.toHaveBeenCalled()
})
})
})

+ 0
- 163
tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts View File

@@ -1,163 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'

// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))

describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry

beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})

describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)

expect(registry.getControlCount()).toBe(1)
})

it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)

registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})

it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()

registry.register(control1, callback1)
registry.register(control2, callback2)

expect(registry.getControlCount()).toBe(2)

registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})

it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')

expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})

describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')

registry.register(controlId, mockCallback)
registry.executeControls('before')

expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})

it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')

registry.register(controlId, mockCallback)
registry.executeControls('before')

expect(mockCallback).not.toHaveBeenCalled()
})

it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()

mockGetSetting.mockReturnValue('before')

registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')

expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})

it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')

expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})

it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)

// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)

// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})

describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)

const control1 = Symbol('control1')
const control2 = Symbol('control2')

registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)

registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)

registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})

it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')

registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)

registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})

+ 3
- 1
vite.config.mts View File

@@ -27,6 +27,7 @@ const ANALYZE_BUNDLE = process.env.ANALYZE_BUNDLE === 'true'
const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'

// Open Graph / Twitter Meta Tags Constants
const VITE_OG_URL = 'https://cloud.comfy.org'
@@ -53,7 +54,8 @@ const DISTRIBUTION: 'desktop' | 'localhost' | 'cloud' =
// Disable Vue DevTools for production cloud distribution
const DISABLE_VUE_PLUGINS =
process.env.DISABLE_VUE_PLUGINS === 'true' ||
(DISTRIBUTION === 'cloud' && !IS_DEV)
(DISTRIBUTION === 'cloud' && !IS_DEV) ||
IS_STORYBOOK

const DEV_SEVER_FALLBACK_URL =
DISTRIBUTION === 'cloud'


Loading…
Cancel
Save
Baidu
map