Compare commits

...

14 Commits

Author SHA1 Message Date
bymyself
965bc83b11 refactor(utils): make RGB/HSB types internal to satisfy knip (unused exports) 2025-09-12 21:18:28 -07:00
bymyself
df2fff1662 feat(utils): add normalizeColorToHex with guard rails; refactor WidgetColorPicker to use it; tests for invalid inputs and coercion 2025-09-12 20:56:17 -07:00
bymyself
e4371a7770 test(utils): add conversion tests for colorUtil (hex/rgb/hsl/hsb round-trips and clamping) 2025-09-12 19:58:57 -07:00
bymyself
5c7a2042f0 test(widgets): use data-testid selector for color text instead of generic span; add data-testid to component span for robustness 2025-09-12 19:53:29 -07:00
bymyself
bec6fd81ab feat(widgets): normalize color widget values to #hex across inputs (hex/rgb/hsb); always emit with leading # using colorUtil conversions 2025-09-12 19:51:45 -07:00
bymyself
8ca9a284c4 test(widgets): normalize color display to single leading # to address review question (AustinMroz) 2025-09-12 19:39:08 -07:00
bymyself
714bf4ca4e test(widgets): use expect.soft for valid hex colors loop (suggestion by DrJKL) 2025-09-12 19:37:31 -07:00
bymyself
9520ecd46b test(widgets): make color text assertion specific in WidgetColorPicker.test per review (DrJKL) 2025-09-12 19:35:55 -07:00
bymyself
e7b44acc04 fix color picker value prefix and add component tests 2025-09-09 21:32:34 -07:00
Christian Byrne
97d00ea47d [test] Add component tests for Vue node slots (#5461)
* add component tests for slots

* use `for of` for better error report

* add runtime type check to make assertions valid

* add runtime type check to make assertions valid
2025-09-09 18:48:51 -07:00
Christian Byrne
0a2260a666 Add design system color variables to tailwind config and use in Vue Nodes (#5430)
* use tailwind colors for

* add updated tokens with scales
2025-09-09 18:45:55 -07:00
Jin Yi
5b834acc86 feat(tailwind): add lucide icon support via iconify plugin (#5453) 2025-09-10 01:20:25 +00:00
Arjan Singh
7d4437c724 [fix] assets service review nits (#5444)
* [fix] assets service review nits

* [fix] lint
2025-09-09 17:00:23 -07:00
Benjamin Lu
9997053290 chore(lint): make ESLint concurrency configurable via pnpm config (#5448)
* chore(lint): make ESLint concurrency configurable via .env (default auto)

* Change default to be 4

* Change to config approach
2025-09-09 15:51:43 -07:00
34 changed files with 1048 additions and 359 deletions

View File

@@ -33,3 +33,4 @@ DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -51,8 +51,7 @@ const config: KnipConfig = {
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
],
ignoreUnresolved: ['^~icons/']
]
}
export default config

View File

@@ -25,10 +25,10 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache --concurrency=auto",
"lint:fix": "eslint src --cache --fix --concurrency=auto",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"lint": "eslint src --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix": "eslint src --fix --cache --concurrency=$npm_package_config_eslint_concurrency",
"lint:no-cache": "eslint src --concurrency=$npm_package_config_eslint_concurrency",
"lint:fix:no-cache": "eslint src --fix --concurrency=$npm_package_config_eslint_concurrency",
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
@@ -37,8 +37,12 @@
"storybook": "nx storybook -p 6006",
"build-storybook": "storybook build"
},
"config": {
"eslint_concurrency": "4"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
@@ -76,7 +80,6 @@
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.1",

22
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.12.0
'@iconify-json/lucide':
specifier: ^1.2.66
version: 1.2.66
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
@@ -282,9 +285,6 @@ importers:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
lucide-vue-next:
specifier: ^0.540.0
version: 0.540.0(vue@3.5.13(typescript@5.9.2))
nx:
specifier: 21.4.1
version: 21.4.1
@@ -1595,6 +1595,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/lucide@1.2.66':
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -4736,11 +4739,6 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide-vue-next@0.540.0:
resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==}
peerDependencies:
vue: '>=3.0.1'
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -8024,6 +8022,10 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/lucide@1.2.66':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
@@ -11563,10 +11565,6 @@ snapshots:
lru-cache@8.0.5: {}
lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
lz-string@1.5.0: {}
magic-string@0.30.17:

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
@@ -33,13 +32,13 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton, Trophy },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
`
}),
@@ -51,13 +50,13 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconButton, Settings },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
`
}),
@@ -69,13 +68,13 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconButton, X },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
`
}),
@@ -87,13 +86,13 @@ export const Transparent: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconButton, Bell },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Bell :size="12" />
<i class="icon-[lucide--bell] size-3" />
</IconButton>
`
}),
@@ -105,42 +104,42 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
components: { IconButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" />
<i class="icon-[lucide--trophy] size-3" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" />
<i class="icon-[lucide--x] size-3" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" />
<i class="icon-[lucide--bell] size-4" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
@@ -17,17 +16,17 @@ type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
components: { IconGroup, IconButton },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" />
<i class="icon-[lucide--external-link] size-4" />
</IconButton>
</IconGroup>
`

View File

@@ -1,14 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
@@ -49,14 +39,14 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton, Package },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Package :size="16" />
<i class="icon-[lucide--package] size-4" />
</template>
</IconTextButton>
`
@@ -70,14 +60,14 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton, Settings },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
`
@@ -91,14 +81,14 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton, X },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</template>
</IconTextButton>
`
@@ -112,14 +102,14 @@ export const Transparent: Story = {
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton, ChevronRight },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
`
@@ -134,14 +124,14 @@ export const WithIconRight: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconTextButton, Save },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Save :size="12" />
<i class="icon-[lucide--save] size-3" />
</template>
</IconTextButton>
`
@@ -156,66 +146,60 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
IconTextButton
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<Trash2 :size="12" />
<i class="icon-[lucide--trash-2] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<Trash2 :size="16" />
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<ChevronLeft :size="16" />
<i class="icon-[lucide--chevron-left] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<Save :size="16" />
<i class="icon-[lucide--save] size-4" />
</template>
</IconTextButton>
</div>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
@@ -18,7 +17,7 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText },
components: { MoreButton, IconTextButton },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
@@ -29,7 +28,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
@@ -39,7 +38,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<ScrollText :size="16" />
<i class="icon-[lucide--scroll-text] size-4" />
</template>
</IconTextButton>
</template>

View File

@@ -1,13 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Heart,
Info,
MoreVertical,
Star,
Upload
} from 'lucide-vue-next'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
@@ -149,14 +140,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
SquareChip
},
setup() {
const favorited = ref(false)
@@ -202,14 +186,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')"
>
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
<IconButton
class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite"
>
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
</IconButton>
</template>
@@ -222,7 +206,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -409,11 +393,7 @@ export const GridOfCards: Story = {
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
SquareChip
},
setup() {
const cards = ref([
@@ -500,7 +480,7 @@ export const GridOfCards: Story = {
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
@@ -511,7 +491,7 @@ export const GridOfCards: Story = {
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
<SquareChip :label="card.size" />

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
@@ -57,7 +56,7 @@ export const Default: Story = {
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
@@ -67,7 +66,7 @@ export const WithIcon: Story = {
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
@@ -94,7 +93,7 @@ export const Preselected: Story = {
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
@@ -110,7 +109,7 @@ export const AllVariants: Story = {
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>

View File

@@ -136,10 +136,6 @@
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DownloadIcon from '~icons/lucide/download'
import Grid3x3Icon from '~icons/lucide/grid-3-x-3'
import LayersIcon from '~icons/lucide/layers'
import TagIcon from '~icons/lucide/tag'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -177,20 +173,20 @@ const sortOptions = ref([
])
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed', icon: DownloadIcon },
{ id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5', icon: TagIcon },
{ id: 'tag-sdxl', label: 'SDXL', icon: TagIcon },
{ id: 'tag-utility', label: 'Utility', icon: TagIcon }
{ id: 'tag-sd15', label: 'SD 1.5', icon: 'icon-[lucide--tag]' },
{ id: 'tag-sdxl', label: 'SDXL', icon: 'icon-[lucide--tag]' },
{ id: 'tag-utility', label: 'Utility', icon: 'icon-[lucide--tag]' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models', icon: LayersIcon },
{ id: 'cat-nodes', label: 'Nodes', icon: Grid3x3Icon }
{ id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
{ id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
]
}
])

View File

@@ -1,20 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { h, provide, ref } from 'vue'
import { provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -94,20 +79,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
SquareChip
},
setup() {
const t = (k: string) => k
@@ -121,7 +93,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'installed',
label: 'Installed',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--folder]'
},
{
title: 'TAGS',
@@ -129,17 +101,17 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--tag]'
}
]
},
@@ -149,12 +121,12 @@ const createStoryTemplate = (args: StoryArgs) => ({
{
id: 'cat-models',
label: 'Models',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: { render: () => h(Folder, { size: 14 }) } as any
icon: 'icon-[lucide--grid-3x3]'
}
]
}
@@ -205,7 +177,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -227,7 +199,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -239,7 +211,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -249,7 +221,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -280,7 +252,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -301,7 +273,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -309,7 +281,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -329,7 +301,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -351,7 +323,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -363,7 +335,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -373,7 +345,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -401,7 +373,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -422,7 +394,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -430,7 +402,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>

View File

@@ -1,7 +1,5 @@
<template>
<span v-if="icon" class="text-xs text-neutral">
<component :is="icon" />
</span>
<i :class="icon" class="text-xs text-neutral" />
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, Folder, Grid3x3, Layers, Tag, Wrench } from 'lucide-vue-next'
import { h } from 'vue'
import NavItem from './NavItem.vue'
@@ -34,31 +32,6 @@ const meta: Meta<typeof NavItem> = {
export default meta
type Story = StoryObj<typeof meta>
export const Interactive: Story = {
args: {
icon: Folder,
active: false,
default: 'Navigation Item'
},
render: (args) => ({
components: { NavItem },
setup() {
const IconComponent = args.icon
const WrappedIcon = {
render() {
return h(IconComponent, { size: 14 })
}
}
return { args, WrappedIcon }
},
template: `
<NavItem :icon="WrappedIcon" :active="args.active" :on-click="() => {}">
{{ args.default }}
</NavItem>
`
})
}
export const InteractiveList: Story = {
render: () => ({
components: { NavItem },
@@ -67,7 +40,7 @@ export const InteractiveList: Story = {
<NavItem
v-for="item in items"
:key="item.id"
:icon="item.wrappedIcon"
:icon="item.icon"
:active="selectedId === item.id"
:on-click="() => selectedId = item.id"
>
@@ -85,32 +58,32 @@ export const InteractiveList: Story = {
{
id: 'downloads',
label: 'Downloads',
wrappedIcon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
wrappedIcon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
wrappedIcon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
},
{
id: 'tags',
label: 'Tags',
wrappedIcon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'settings',
label: 'Settings',
wrappedIcon: () => h(Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
id: 'default',
label: 'Default Icon',
wrappedIcon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
}
]

View File

@@ -1,16 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Grid3x3,
Layers,
Puzzle,
Settings,
Tag,
Wrench,
Zap
} from 'lucide-vue-next'
import { h, ref } from 'vue'
import { ref } from 'vue'
import LeftSidePanel from './LeftSidePanel.vue'
@@ -48,22 +37,22 @@ export const Default: Story = {
{
id: 'installed',
label: 'Installed',
icon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
}
]
},
render: (args) => ({
components: { LeftSidePanel, Puzzle },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -72,7 +61,7 @@ export const Default: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
@@ -90,7 +79,7 @@ export const WithGroups: Story = {
{
id: 'installed',
label: 'Installed',
icon: () => h(Download, { size: 14 })
icon: 'icon-[lucide--download]'
},
{
title: 'TAGS',
@@ -98,17 +87,17 @@ export const WithGroups: Story = {
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: () => h(Tag, { size: 14 })
icon: 'icon-[lucide--tag]'
}
]
},
@@ -118,19 +107,19 @@ export const WithGroups: Story = {
{
id: 'cat-models',
label: 'Models',
icon: () => h(Layers, { size: 14 })
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: () => h(Grid3x3, { size: 14 })
icon: 'icon-[lucide--grid-3x3]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel, Puzzle },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -139,7 +128,7 @@ export const WithGroups: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
@@ -160,27 +149,27 @@ export const DefaultIcons: Story = {
{
id: 'home',
label: 'Home',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'documents',
label: 'Documents',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'downloads',
label: 'Downloads',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
},
{
id: 'desktop',
label: 'Desktop',
icon: () => h(Folder, { size: 14 })
icon: 'icon-[lucide--folder]'
}
]
},
render: (args) => ({
components: { LeftSidePanel, Folder },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -189,7 +178,7 @@ export const DefaultIcons: Story = {
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Folder :size="16" class="text-neutral" />
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
@@ -207,12 +196,12 @@ export const LongLabels: Story = {
{
id: 'general',
label: 'General Settings',
icon: () => h(() => Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
id: 'appearance',
label: 'Appearance & Themes Configuration',
icon: () => h(() => Wrench, { size: 14 })
icon: 'icon-[lucide--wrench]'
},
{
title: 'ADVANCED OPTIONS',
@@ -220,19 +209,19 @@ export const LongLabels: Story = {
{
id: 'performance',
label: 'Performance & Optimization Settings',
icon: () => h(() => Zap, { size: 14 })
icon: 'icon-[lucide--zap]'
},
{
id: 'experimental',
label: 'Experimental Features (Beta)',
icon: () => h(() => Puzzle, { size: 14 })
icon: 'icon-[lucide--puzzle]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel, Settings },
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
@@ -241,7 +230,7 @@ export const LongLabels: Story = {
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<Settings :size="16" class="text-neutral" />
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>

View File

@@ -977,8 +977,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
tooltip:
'Use new asset API instead of experiment endpoints for model browsing',
tooltip: 'Use new Asset API for model browsing',
defaultValue: false,
experimental: true
}

View File

@@ -7,7 +7,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-[#15161A]',
'bg-white dark-theme:bg-charcoal-primary',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2',
@@ -16,7 +16,8 @@
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'border-sand-primary dark-theme:border-charcoal-tertiary':
!isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
@@ -226,7 +227,8 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
const separatorClasses =
'bg-sand-primary dark-theme:bg-charcoal-tertiary h-[1px] mx-0'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(

View File

@@ -0,0 +1,195 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { type PropType, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json'
import NodeSlots from './NodeSlots.vue'
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
type: 'TestType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
type?: string
boundingRect?: [number, number, number, number]
}
const InputSlotStub = defineComponent({
name: 'InputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-input-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const OutputSlotStub = defineComponent({
name: 'OutputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-output-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeSlots, {
global: {
plugins: [i18n, createPinia()],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData, readonly }
})
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
name: 'objNoWidget',
type: 'number',
boundingRect: [0, 0, 0, 0]
}
const inputObjWithWidget = {
name: 'objWithWidget',
type: 'number',
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' }
}
const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput']
const wrapper = mountSlots(makeNodeData({ inputs }))
const inputEls = wrapper
.findAll('.stub-input-slot')
.map((w) => w.element as HTMLElement)
// Should filter out the widget-backed input; expect 2 inputs rendered
expect(inputEls.length).toBe(2)
// Verify expected tuple of {index, name, nodeId}
const info = inputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(info).toEqual([
{
index: 0,
name: 'objNoWidget',
nodeId: '123',
type: 'number',
readonly: false
},
// string input is converted to object with default type 'any'
{
index: 1,
name: 'stringInput',
nodeId: '123',
type: 'any',
readonly: false
}
])
// Ensure widget-backed input was indeed filtered out
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
})
it('maps outputs and passes correct indexes', () => {
const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] }
const outputs = [outputObj, 'outB']
const wrapper = mountSlots(makeNodeData({ outputs }))
const outputEls = wrapper
.findAll('.stub-output-slot')
.map((w) => w.element as HTMLElement)
expect(outputEls.length).toBe(2)
const outInfo = outputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(outInfo).toEqual([
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
// string output mapped to object with type 'any'
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
])
})
it('renders nothing when there are no inputs/outputs', () => {
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: ['a'], outputs: ['b'] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -0,0 +1,300 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import type { ColorPickerProps } from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetColorPicker from './WidgetColorPicker.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
describe('WidgetColorPicker Value Binding', () => {
const createMockWidget = (
value: string = '#000000',
options: Partial<ColorPickerProps> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_color_picker',
type: 'color',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) => {
return mount(WidgetColorPicker, {
global: {
plugins: [PrimeVue],
components: {
ColorPicker,
WidgetLayoutField
}
},
props: {
widget,
modelValue,
readonly
}
})
}
const setColorPickerValue = async (
wrapper: ReturnType<typeof mount>,
value: string
) => {
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
await colorPicker.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when color changes', async () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const emitted = await setColorPickerValue(wrapper, '#00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('handles different color formats', async () => {
const widget = createMockWidget('#ffffff')
const wrapper = mountComponent(widget, '#ffffff')
const emitted = await setColorPickerValue(wrapper, '#123abc')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#123abc')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('#000000', {}, undefined)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
// Should still emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff00ff')
})
it('normalizes bare hex without # to #hex on emit', async () => {
const widget = createMockWidget('ff0000')
const wrapper = mountComponent(widget, 'ff0000')
const emitted = await setColorPickerValue(wrapper, '00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async () => {
const widget = createMockWidget('#000000')
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff0000')
})
it('normalizes hsb() strings to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes HSB object values to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, {
h: 240,
s: 100,
b: 100
} as any)
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#0000ff')
})
})
describe('Component Rendering', () => {
it('renders color picker component', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
it('normalizes display to a single leading #', () => {
// Case 1: model value already includes '#'
let widget = createMockWidget('#ff0000')
let wrapper = mountComponent(widget, '#ff0000')
let colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
// Case 2: model value missing '#'
widget = createMockWidget('ff0000')
wrapper = mountComponent(widget, 'ff0000')
colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
})
it('renders layout field wrapper', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
})
it('displays current color value as text', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#ff0000')
})
it('updates color text when value changes', async () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
await setColorPickerValue(wrapper, '#00ff00')
// Need to check the local state update
const colorText = wrapper.find('[data-testid="widget-color-text"]')
// Be specific about the displayed value including the leading '#'
expect.soft(colorText.text()).toBe('#00ff00')
})
it('uses default color when no value provided', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
// Should use the default value from the composable
expect(colorPicker.exists()).toBe(true)
})
})
describe('Readonly Mode', () => {
it('disables color picker when readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', true)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(true)
})
it('enables color picker when not readonly', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000', false)
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('disabled')).toBe(false)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [
'#000000',
'#ffffff',
'#ff0000',
'#00ff00',
'#0000ff',
'#123abc'
]
for (const color of validHexColors) {
const widget = createMockWidget(color)
const wrapper = mountComponent(widget, color)
const colorText = wrapper.find('span')
expect.soft(colorText.text()).toBe(color)
}
})
it('handles short hex colors', () => {
const widget = createMockWidget('#fff')
const wrapper = mountComponent(widget, '#fff')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#fff')
})
it('passes widget options to color picker', () => {
const colorOptions = {
format: 'hex' as const,
inline: true
}
const widget = createMockWidget('#ff0000', colorOptions)
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('format')).toBe('hex')
expect(colorPicker.props('inline')).toBe(true)
})
})
describe('Widget Layout Integration', () => {
it('passes widget to layout field', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget')).toEqual(widget)
})
it('maintains proper component structure', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
// Should have layout field containing label with color picker and text
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const label = wrapper.find('label')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorText = wrapper.find('span')
expect(layoutField.exists()).toBe(true)
expect(label.exists()).toBe(true)
expect(colorPicker.exists()).toBe(true)
expect(colorText.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles empty color value', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
it('handles invalid color formats gracefully', () => {
const widget = createMockWidget('invalid-color')
const wrapper = mountComponent(widget, 'invalid-color')
const colorText = wrapper.find('span')
expect(colorText.text()).toBe('#invalid-color')
})
it('handles widget with no options', () => {
const widget = createMockWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.exists()).toBe(true)
})
})
})

View File

@@ -16,7 +16,9 @@
}"
@update:model-value="onChange"
/>
<span class="text-xs">#{{ localValue }}</span>
<span class="text-xs" data-testid="widget-color-text">{{
localValue.startsWith('#') ? localValue : '#' + localValue
}}</span>
</label>
</WidgetLayoutField>
</template>
@@ -27,6 +29,7 @@ import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { normalizeColorToHex } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
@@ -46,12 +49,13 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
// Normalize initial model value to ensure leading '#'
modelValue: normalizeColorToHex(props.modelValue),
defaultValue: '#000000',
emit
emit,
transform: (val: unknown) => normalizeColorToHex(val)
})
// ColorPicker specific excluded props include panel/overlay classes

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
// Zod schemas for asset API validation
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
size: z.number(),
created_at: z.string().optional()
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zModelFolder = z.object({
name: z.string(),
folders: z.array(z.string())
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
export interface ModelFolderInfo {
name: string
folders: string[]
}

View File

@@ -30,6 +30,7 @@ import type {
User,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ModelFile, ModelFolderInfo } from '@/schemas/assetSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
@@ -675,15 +676,14 @@ export class ComfyApi extends EventTarget {
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
* @returns The list of model folder keys
*/
async getModelFolders(): Promise<{ name: string; folders: string[] }[]> {
async getModelFolders(): Promise<ModelFolderInfo[]> {
const res = await this.fetchApi(`/experiment/models`)
if (res.status === 404) {
return []
}
const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter(
(folder: { name: string; folders: string[] }) =>
!folderBlacklist.includes(folder.name)
(folder: ModelFolderInfo) => !folderBlacklist.includes(folder.name)
)
}
@@ -692,9 +692,7 @@ export class ComfyApi extends EventTarget {
* @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async getModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
async getModels(folder: string): Promise<ModelFile[]> {
const res = await this.fetchApi(`/experiment/models/${folder}`)
if (res.status === 404) {
return []

View File

@@ -1,63 +1,26 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetResponse,
type ModelFile,
type ModelFolder,
assetResponseSchema
} from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
// Types for asset API responses
interface AssetResponse {
assets?: Asset[]
total?: number
has_more?: boolean
}
interface Asset {
id: string
name: string
tags: string[]
size: number
created_at?: string
}
/**
* Type guard for validating asset structure
* Validates asset response data using Zod schema
*/
function isValidAsset(asset: unknown): asset is Asset {
return (
asset !== null &&
typeof asset === 'object' &&
'id' in asset &&
'name' in asset &&
'tags' in asset &&
Array.isArray((asset as Asset).tags)
)
}
function validateAssetResponse(data: unknown): AssetResponse {
const result = assetResponseSchema.safeParse(data)
if (result.success) return result.data
/**
* Creates predicate for filtering assets by folder and excluding missing ones
*/
function createAssetFolderFilter(folder?: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || asset.tags.includes(MISSING_TAG)) {
return false
}
if (folder && !asset.tags.includes(folder)) {
return false
}
return true
}
}
/**
* Creates predicate for filtering folder assets (requires name)
*/
function createFolderAssetFilter(folder: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || !asset.name) {
return false
}
return asset.tags.includes(folder) && !asset.tags.includes(MISSING_TAG)
}
const error = fromZodError(result.error)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
/**
@@ -66,7 +29,7 @@ function createFolderAssetFilter(folder: string) {
*/
function createAssetService() {
/**
* Handles API response with consistent error handling
* Handles API response with consistent error handling and Zod validation
*/
async function handleAssetRequest(
url: string,
@@ -78,7 +41,8 @@ function createAssetService() {
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
return await res.json()
const data = await res.json()
return validateAssetResponse(data)
}
/**
* Gets a list of model folder keys from the asset API
@@ -90,9 +54,7 @@ function createAssetService() {
*
* @returns The list of model folder keys
*/
async function getAssetModelFolders(): Promise<
{ name: string; folders: string[] }[]
> {
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
'model folders'
@@ -102,22 +64,17 @@ function createAssetService() {
const blacklistedDirectories = ['configs']
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>()
if (data?.assets) {
const directoryTags = data.assets
.filter(createAssetFolderFilter())
.flatMap((asset) => asset.tags)
.filter(
const discoveredFolders = new Set<string>(
data?.assets
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
)
for (const tag of directoryTags) {
discoveredFolders.add(tag)
}
}
) ?? []
)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).sort()
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
@@ -126,20 +83,23 @@ function createAssetService() {
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`models for ${folder}`
)
return data?.assets
? data.assets.filter(createFolderAssetFilter(folder)).map((asset) => ({
return (
data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name,
pathIndex: 0
}))
: []
})) ?? []
)
}
return {

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ModelFile } from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore'
@@ -157,9 +158,7 @@ export class ModelFolder {
constructor(
public directory: string,
private getModelsFunc: (
folder: string
) => Promise<{ name: string; pathIndex: number }[]>
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
) {}
get key(): string {

View File

@@ -1,9 +1,7 @@
import { DefineComponent, FunctionalComponent } from 'vue'
export interface NavItemData {
id: string
label: string
icon: DefineComponent | FunctionalComponent
icon: string
}
export interface NavGroupData {

View File

@@ -1,6 +1,7 @@
import { memoize } from 'es-toolkit/compat'
type RGB = { r: number; g: number; b: number }
type HSB = { h: number; s: number; b: number }
type HSL = { h: number; s: number; l: number }
type HSLA = { h: number; s: number; l: number; a: number }
type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
@@ -59,6 +60,128 @@ export function hexToRgb(hex: string): RGB {
return { r, g, b }
}
export function rgbToHex({ r, g, b }: RGB): string {
const toHex = (n: number) =>
Math.max(0, Math.min(255, Math.round(n)))
.toString(16)
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
export function hsbToRgb({ h, s, b }: HSB): RGB {
// Normalize
const hh = ((h % 360) + 360) % 360
const ss = Math.max(0, Math.min(100, s)) / 100
const vv = Math.max(0, Math.min(100, b)) / 100
const c = vv * ss
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
const m = vv - c
let rp = 0,
gp = 0,
bp = 0
if (hh < 60) {
rp = c
gp = x
bp = 0
} else if (hh < 120) {
rp = x
gp = c
bp = 0
} else if (hh < 180) {
rp = 0
gp = c
bp = x
} else if (hh < 240) {
rp = 0
gp = x
bp = c
} else if (hh < 300) {
rp = x
gp = 0
bp = c
} else {
rp = c
gp = 0
bp = x
}
return {
r: Math.round((rp + m) * 255),
g: Math.round((gp + m) * 255),
b: Math.round((bp + m) * 255)
}
}
/**
* Normalize various color inputs (hex, rgb/rgba, hsl/hsla, hsb string/object)
* into lowercase #rrggbb. Falls back to #000000 on invalid inputs.
*/
export function normalizeColorToHex(value: unknown): string {
// String inputs
if (typeof value === 'string') {
const raw = value.trim()
if (!raw) return '#000000'
// Bare hex without '#'
if (/^[0-9a-fA-F]{3}$/.test(raw) || /^[0-9a-fA-F]{6}$/.test(raw)) {
return `#${raw.toLowerCase()}`
}
// Starts with '#': only accept #rgb or #rrggbb
if (raw.startsWith('#')) {
const hex = raw.toLowerCase()
if (hex.length === 4 || hex.length === 7) return hex
return '#000000'
}
// rgb(), rgba(), hsl(), hsla()
if (/^(rgb|rgba|hsl|hsla)\(/i.test(raw)) {
const rgb = parseToRgb(raw)
return rgbToHex(rgb).toLowerCase()
}
// hsb(h,s,b)
if (/^hsb\(/i.test(raw)) {
const nums = raw.match(/\d+(?:\.\d+)?/g)?.map(Number) || []
if (nums.length >= 3) {
const [h, s, b] = nums
if ([h, s, b].every((n) => Number.isFinite(n))) {
const rgb = hsbToRgb({ h, s, b })
return rgbToHex(rgb).toLowerCase()
}
}
return '#000000'
}
// Unknown string format -> default
return '#000000'
}
// HSB object (PrimeVue)
if (
value &&
typeof value === 'object' &&
'h' in (value as any) &&
's' in (value as any) &&
('b' in (value as any) || 'v' in (value as any))
) {
const h = Number((value as any).h)
const s = Number((value as any).s)
const b = Number((value as any).b ?? (value as any).v)
if ([h, s, b].every((n) => Number.isFinite(n))) {
const rgb = hsbToRgb({ h, s, b })
return rgbToHex(rgb).toLowerCase()
}
return '#000000'
}
// Fallback
return '#000000'
}
export function parseToRgb(color: string): RGB {
const format = identifyColorFormat(color)
if (!format) return { r: 0, g: 0, b: 0 }

View File

@@ -1,3 +1,4 @@
import lucide from '@iconify-json/lucide/icons.json'
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './build/customIconCollection'
@@ -188,7 +189,72 @@ export default {
800: '#b38400',
900: '#997200',
950: '#664d00'
}
},
// Base colors
'pure-black': '#000000',
'pure-white': '#FFFFFF',
// Charcoal palette
'charcoal-100': '#171718',
'charcoal-200': '#202121',
'charcoal-300': '#262729',
'charcoal-400': '#2D2E32',
'charcoal-500': '#313235',
'charcoal-600': '#3C3D42',
'charcoal-700': '#494A50',
'charcoal-800': '#55565E',
// Stone palette
'stone-100': '#444444',
'stone-200': '#828282',
'stone-300': '#BBBBBB',
// Ivory palette
'ivory-100': '#FDFBFA',
'ivory-200': '#FAF9F5',
'ivory-300': '#F0EEE6',
// Gray palette
'gray-100': '#F3F3F3',
'gray-200': '#E9E9E9',
'gray-300': '#E1E1E1',
'gray-400': '#D9D9D9',
'gray-500': '#C5C5C5',
'gray-600': '#B4B4B4',
'gray-700': '#A0A0A0',
'gray-800': '#8A8A8A',
// Sand palette
'sand-100': '#E1DED5',
'sand-200': '#D6CFC2',
'sand-300': '#888682',
// Slate palette
'slate-100': '#9C9EAB',
'slate-200': '#9FA2BD',
'slate-300': '#5B5E7D',
// Brand colors
'brand-yellow': '#F0FF41',
'brand-blue': '#172DD7',
// Functional colors
'blue-100': '#0B8CE9',
'blue-200': '#31B9F4',
'success-100': '#00CD72',
'success-200': '#47E469',
'warning-100': '#FD9903',
'warning-200': '#FCBF64',
'danger-100': '#C02323',
'danger-200': '#D62952',
error: '#962A2A',
// Alpha variants
'blue-selection': 'rgba(11, 140, 233, 0.3)',
'node-hover-100': 'rgba(85, 86, 94, 0.15)',
'node-hover-200': 'rgba(85, 86, 94, 0.1)',
'modal-tag': 'rgba(217, 217, 217, 0.4)'
},
textColor: {
@@ -235,8 +301,10 @@ export default {
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection
}
comfy: iconCollection,
lucide
},
prefix: 'icon'
}),
function ({ addVariant }) {
addVariant('dark-theme', '.dark-theme &')

View File

@@ -83,19 +83,20 @@ describe('assetService', () => {
expect(folderNames).not.toContain('configs')
})
it('should handle errors and empty responses', async () => {
// Empty response
it('should handle empty responses', async () => {
mockApiResponse([])
const emptyResult = await assetService.getAssetModelFolders()
expect(emptyResult).toHaveLength(0)
})
// Network error
it('should handle network errors', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Network error'
)
})
// HTTP error
it('should handle HTTP errors', async () => {
mockApiError(500)
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Unable to load model folders: Server returned 500. Please try again.'
@@ -107,7 +108,6 @@ describe('assetService', () => {
it('should return filtered models for folder', async () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.checkpoints, name: undefined }, // Invalid name
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
{
id: 'uuid-4',

View File

@@ -38,7 +38,9 @@ function enableMocks(useAssetAPI = false) {
return false
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
mockSettingStore
)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
hexToRgb,
hsbToRgb,
normalizeColorToHex,
parseToRgb,
rgbToHex
} from '@/utils/colorUtil'
describe('colorUtil conversions', () => {
describe('hexToRgb / rgbToHex', () => {
it('converts 6-digit hex to RGB', () => {
expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts 3-digit hex to RGB', () => {
expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts RGB to lowercase #hex and clamps values', () => {
expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000')
expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00')
expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff')
// out-of-range should clamp
expect(rgbToHex({ r: -10, g: 300, b: 16 })).toBe('#00ff10')
})
it('round-trips #hex -> rgb -> #hex', () => {
const hex = '#123abc'
expect(rgbToHex(hexToRgb(hex))).toBe('#123abc')
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses rgb()/rgba()', () => {
expect(parseToRgb('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 })
expect(parseToRgb('rgba(255,0,0,0.5)')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses hsl()/hsla()', () => {
expect(parseToRgb('hsl(0, 100%, 50%)')).toEqual({ r: 255, g: 0, b: 0 })
const green = parseToRgb('hsla(120, 100%, 50%, 0.7)')
expect(green.r).toBe(0)
expect(green.g).toBe(255)
expect(green.b).toBe(0)
})
})
describe('hsbToRgb', () => {
it('converts HSB to primary RGB colors', () => {
expect(hsbToRgb({ h: 0, s: 100, b: 100 })).toEqual({ r: 255, g: 0, b: 0 })
expect(hsbToRgb({ h: 120, s: 100, b: 100 })).toEqual({
r: 0,
g: 255,
b: 0
})
expect(hsbToRgb({ h: 240, s: 100, b: 100 })).toEqual({
r: 0,
g: 0,
b: 255
})
})
it('handles non-100 brightness and clamps/normalizes input', () => {
const rgb = hsbToRgb({ h: 360, s: 150, b: 50 })
expect(rgbToHex(rgb)).toBe('#7f0000')
})
})
describe('normalizeColorToHex (guard rails)', () => {
it('returns #hex for common inputs and falls back to black', () => {
expect(normalizeColorToHex('#FFaa00')).toBe('#ffaa00')
expect(normalizeColorToHex('ffaa00')).toBe('#ffaa00')
expect(normalizeColorToHex('rgb(300, -5, 16)')).toBe('#ff0010')
expect(normalizeColorToHex('hsl(0, 100%, 50%)')).toBe('#ff0000')
expect(normalizeColorToHex('hsb(120, 100, 100)')).toBe('#00ff00')
// invalid strings
expect(normalizeColorToHex('')).toBe('#000000')
expect(normalizeColorToHex(' ')).toBe('#000000')
expect(normalizeColorToHex('#12')).toBe('#000000')
expect(normalizeColorToHex('#zzzzzz')).toBe('#000000')
expect(normalizeColorToHex('not-a-color')).toBe('#000000')
// HSB object inputs
expect(normalizeColorToHex({ h: 240, s: 100, b: 100 } as any)).toBe(
'#0000ff'
)
expect(normalizeColorToHex({ h: NaN, s: 100, b: 100 } as any)).toBe(
'#000000'
)
expect(normalizeColorToHex(null)).toBe('#000000')
expect(normalizeColorToHex(undefined)).toBe('#000000')
})
})
})

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "ES2023.Array", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"incremental": true,
"sourceMap": true,

View File

@@ -20,11 +20,19 @@ export default defineConfig({
retry: process.env.CI ? 2 : 0,
include: [
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
reporter: ['text', 'json', 'html']
}
},
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
'src/lib/litegraph/test/**'
]
},
resolve: {
alias: {