Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Action
a3d9a6045e [automated] Apply ESLint and Oxfmt fixes 2026-03-08 01:53:41 +00:00
bymyself
8d1ab9bc98 test: add 106 Playwright E2E tests covering UI coverage gaps
Add comprehensive E2E test coverage across 18 spec files and 2 test helpers:

Infrastructure:
- FeatureFlagHelper: manage localStorage feature flags and mock /api/features
- QueueHelper: mock queue API routes and wait for completion

Wave 1 (28 tests): toast notifications, error overlay, selection toolbox
actions, linear mode, selection rectangle for vue nodes

Wave 2 (30 tests): V2 node search, bottom panel logs, focus mode, job
history actions, right side panel tabs

Wave 3 (24 tests): errors tab interactions, vue node header actions,
queue notification banners, settings sidebar button

Wave 4 (24 tests): minimap status, widget copy button, floating menus,
node library essentials tab
2026-03-07 17:50:20 -08:00
342 changed files with 3013 additions and 1869 deletions

View File

@@ -25,9 +25,11 @@ import {
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
@@ -184,9 +186,11 @@ export class ComfyPage {
public readonly contextMenu: ContextMenu
public readonly toast: ToastHelper
public readonly dragDrop: DragDropHelper
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -229,9 +233,11 @@ export class ComfyPage {
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.queue = new QueueHelper(page)
}
get visibleToasts() {

View File

@@ -0,0 +1,47 @@
import type { Page } from '@playwright/test'
export class FeatureFlagHelper {
constructor(private readonly page: Page) {}
/**
* Set feature flags via localStorage. Uses the `ff:` prefix
* that devFeatureFlagOverride.ts reads in dev mode.
* Call BEFORE comfyPage.setup() for flags needed at init time,
* or use page.evaluate() for runtime changes.
*/
async setFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
for (const [key, value] of Object.entries(flagMap)) {
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
}
}, flags)
}
async setFlag(name: string, value: unknown): Promise<void> {
await this.setFlags({ [name]: value })
}
async clearFlags(): Promise<void> {
await this.page.evaluate(() => {
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('ff:')) keysToRemove.push(key)
}
keysToRemove.forEach((k) => localStorage.removeItem(k))
})
}
/**
* Mock server feature flags via route interception on /api/features.
*/
async mockServerFeatures(features: Record<string, unknown>): Promise<void> {
await this.page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(features)
})
)
}
}

View File

@@ -0,0 +1,70 @@
import type { Page } from '@playwright/test'
export class QueueHelper {
constructor(private readonly page: Page) {}
/**
* Mock the /api/queue endpoint to return specific queue state.
*/
async mockQueueState(
running: number = 0,
pending: number = 0
): Promise<void> {
await this.page.route('**/api/queue', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: Array.from({ length: running }, (_, i) => [
i,
`running-${i}`,
{},
{},
[]
]),
queue_pending: Array.from({ length: pending }, (_, i) => [
i,
`pending-${i}`,
{},
{},
[]
])
})
})
)
}
/**
* Mock the /api/history endpoint with completed/failed job entries.
*/
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
prompt: [0, job.promptId, {}, {}, []],
outputs: {},
status: {
status_str: job.status === 'success' ? 'success' : 'error',
completed: job.status === 'success'
}
}
}
await this.page.route('**/api/history**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(history)
})
)
}
/**
* Clear all route mocks set by this helper.
*/
async clearMocks(): Promise<void> {
await this.page.unroute('**/api/queue')
await this.page.unroute('**/api/history**')
}
}

View File

@@ -0,0 +1,116 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should open bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
})
test('should show Logs tab when terminal panel opens', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasLogs = await logsTab.isVisible().catch(() => false)
if (hasLogs) {
await expect(logsTab).toBeVisible()
}
})
test('should close bottom panel via toggle button', async ({ comfyPage }) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).not.toBeVisible()
})
test('should switch between shortcuts and terminal panels', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await expect(bottomPanel.root).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).toBeVisible()
await bottomPanel.toggleButton.click()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasTerminalTabs = await logsTab.isVisible().catch(() => false)
if (hasTerminalTabs) {
await expect(logsTab).toBeVisible()
await expect(
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
).not.toBeVisible()
}
})
test('should persist Logs tab content in bottom panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasLogs = await logsTab.isVisible().catch(() => false)
if (hasLogs) {
await logsTab.click()
const xtermContainer = bottomPanel.root.locator('.xterm')
const hasXterm = await xtermContainer.isVisible().catch(() => false)
if (hasXterm) {
await expect(xtermContainer).toBeVisible()
}
}
})
test('should render xterm container in terminal panel', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.toggleButton.click()
await expect(bottomPanel.root).toBeVisible()
const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i })
const hasLogs = await logsTab.isVisible().catch(() => false)
if (hasLogs) {
await logsTab.click()
const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen')
const hasXterm = await xtermScreen
.first()
.isVisible()
.catch(() => false)
if (hasXterm) {
await expect(xtermScreen.first()).toBeVisible()
}
}
})
})

View File

@@ -0,0 +1,88 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})

View File

@@ -0,0 +1,148 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test.describe('Errors tab in right side panel', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
test('Errors tab appears after execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
// Dismiss the error overlay
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
await expect(
propertiesPanel.root.getByRole('tab', { name: 'Errors' })
).toBeVisible()
})
test('Error card shows locate button', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const locateButton = propertiesPanel.root.locator(
'button .icon-\\[lucide--locate\\]'
)
await expect(locateButton.first()).toBeVisible()
})
test('Clicking locate button focuses canvas', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const locateButton = propertiesPanel.root
.locator('button')
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--locate\\]') })
.first()
await locateButton.click()
await expect(comfyPage.canvas).toBeVisible()
})
test('Collapse all button collapses error groups', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const collapseButton = propertiesPanel.root.getByRole('button', {
name: 'Collapse all'
})
// The collapse toggle only appears when there are multiple groups.
// If only one group exists, this test verifies the button is not shown.
const count = await collapseButton.count()
if (count > 0) {
await collapseButton.click()
const expandButton = propertiesPanel.root.getByRole('button', {
name: 'Expand all'
})
await expect(expandButton).toBeVisible()
}
})
test('Search filters errors', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
// Search for a term that won't match any error
await propertiesPanel.searchBox.fill('zzz_nonexistent_zzz')
await expect(
propertiesPanel.root.locator('.icon-\\[lucide--locate\\]')
).toHaveCount(0)
// Clear the search to restore results
await propertiesPanel.searchBox.fill('')
await expect(
propertiesPanel.root.locator('.icon-\\[lucide--locate\\]').first()
).toBeVisible()
})
test('Errors tab shows error message text', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
// Error cards contain <p> elements with error messages
const errorMessage = propertiesPanel.root
.locator('.whitespace-pre-wrap')
.first()
await expect(errorMessage).toBeVisible()
await expect(errorMessage).not.toHaveText('')
})
})

View File

@@ -0,0 +1,75 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Floating Canvas Menus', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.setup()
})
test('Floating menu is visible on canvas', async ({ comfyPage }) => {
const toggleLinkButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleLinkVisibilityButton
)
const toggleMinimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleLinkButton).toBeVisible()
await expect(toggleMinimapButton).toBeVisible()
})
test('Link visibility toggle button is present', async ({ comfyPage }) => {
const button = comfyPage.page.getByTestId(
TestIds.canvas.toggleLinkVisibilityButton
)
await expect(button).toBeVisible()
await expect(button).toBeEnabled()
})
test('Clicking link toggle changes link render mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.LinkRenderMode', 2)
const button = comfyPage.page.getByTestId(
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.nextFrame()
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
return window.LiteGraph!.HIDDEN_LINK
})
expect(await comfyPage.settings.getSetting('Comfy.LinkRenderMode')).toBe(
hiddenLinkRenderMode
)
})
test('Zoom controls button shows percentage', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId('zoom-controls-button')
await expect(zoomButton).toBeVisible()
await expect(zoomButton).toContainText('%')
})
test('Fit view button is present and clickable', async ({ comfyPage }) => {
const fitViewButton = comfyPage.page
.locator('button')
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--focus\\]') })
await expect(fitViewButton).toBeVisible()
await fitViewButton.click()
await comfyPage.nextFrame()
})
test('Zoom controls popup opens on click', async ({ comfyPage }) => {
const zoomButton = comfyPage.page.getByTestId('zoom-controls-button')
await zoomButton.click()
await comfyPage.nextFrame()
const zoomModal = comfyPage.page.getByText('Zoom To Fit')
await expect(zoomModal).toBeVisible()
})
})

View File

@@ -0,0 +1,65 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Focus Mode', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Focus mode hides UI chrome', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
})
test('Focus mode restores UI chrome', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('Toggle focus mode command works', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.menu.sideToolbar).toBeVisible()
})
test('Focus mode hides topbar', async ({ comfyPage }) => {
const topMenu = comfyPage.page.locator('.comfy-menu-button-wrapper')
await expect(topMenu).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(topMenu).not.toBeVisible()
})
test('Canvas remains visible in focus mode', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.canvas).toBeVisible()
})
test('Focus mode can be toggled multiple times', async ({ comfyPage }) => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
await comfyPage.setFocusMode(false)
await expect(comfyPage.menu.sideToolbar).toBeVisible()
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
})
})

View File

@@ -0,0 +1,96 @@
import type { Locator } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Job History Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function openMoreOptionsPopover(comfyPage: {
page: { locator(sel: string): Locator }
}) {
const moreButton = comfyPage.page
.locator('.icon-\\[lucide--more-horizontal\\]')
.first()
await moreButton.click()
}
test('More options popover opens', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="docked-job-history-action"]')
).toBeVisible()
})
test('Docked job history action is visible with text', async ({
comfyPage
}) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await expect(action).not.toBeEmpty()
})
test('Show run progress bar action is visible', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="show-run-progress-bar-action"]')
).toBeVisible()
})
test('Clear history action is visible', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="clear-history-action"]')
).toBeVisible()
})
test('Clicking docked job history closes popover', async ({ comfyPage }) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="docked-job-history-action"]'
)
await expect(action).toBeVisible()
await action.click()
await expect(action).not.toBeVisible()
})
test('Clicking show run progress bar toggles check icon', async ({
comfyPage
}) => {
await openMoreOptionsPopover(comfyPage)
const action = comfyPage.page.locator(
'[data-testid="show-run-progress-bar-action"]'
)
const checkIcon = action.locator('.icon-\\[lucide--check\\]')
const hadCheck = await checkIcon.isVisible()
await action.click()
await openMoreOptionsPopover(comfyPage)
const checkIconAfter = comfyPage.page
.locator('[data-testid="show-run-progress-bar-action"]')
.locator('.icon-\\[lucide--check\\]')
if (hadCheck) {
await expect(checkIconAfter).not.toBeVisible()
} else {
await expect(checkIconAfter).toBeVisible()
}
})
})

View File

@@ -0,0 +1,87 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (workflow) workflow.activeMode = 'app'
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (workflow) workflow.activeMode = 'graph'
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await enterGraphMode(comfyPage)
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await expect(comfyPage.canvas).not.toBeVisible()
})
})

View File

@@ -29,23 +29,11 @@ test.describe(
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
// 'workflow.avif'
]
const filesWithUpload = new Set(['no_workflow.webp'])
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
}
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

View File

@@ -0,0 +1,71 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Minimap Status', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.setup()
})
test('Minimap is visible when enabled', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
})
test('Minimap contains canvas and viewport elements', async ({
comfyPage
}) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap.locator('.minimap-canvas')).toBeVisible()
await expect(minimap.locator('.minimap-viewport')).toBeVisible()
})
test('Minimap toggle button exists in canvas menu', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
test('Minimap can be hidden via toggle', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(minimap).toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
})
test('Minimap can be re-shown via toggle', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test('Minimap persists across queue operations', async ({ comfyPage }) => {
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
})

View File

@@ -0,0 +1,99 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Node library opens via sidebar', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const sidebarContent = comfyPage.page.locator(
'.comfy-vue-side-bar-container'
)
await expect(sidebarContent).toBeVisible()
})
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await expect(essentialsTab).toBeVisible()
})
test('Clicking essentials tab shows essentials panel', async ({
comfyPage
}) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
})
test('Essential node cards are displayed', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(async () => {
expect(await essentialCards.count()).toBeGreaterThan(0)
}).toPass()
})
test('Essential node cards have node names', async ({ comfyPage }) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
await essentialsTab.click()
const firstCard = comfyPage.page.locator('[data-node-name]').first()
await expect(firstCard).toBeVisible()
const nodeName = await firstCard.getAttribute('data-node-name')
expect(nodeName).toBeTruthy()
expect(nodeName!.length).toBeGreaterThan(0)
})
test('Node library can switch between all and essentials tabs', async ({
comfyPage
}) => {
const tabButton = comfyPage.page.locator('.node-library-tab-button')
await tabButton.click()
const essentialsTab = comfyPage.page.getByRole('tab', {
name: /essentials/i
})
const allNodesTab = comfyPage.page.getByRole('tab', {
name: /all nodes/i
})
await essentialsTab.click()
const essentialCards = comfyPage.page.locator('[data-node-name]')
await expect(essentialCards.first()).toBeVisible()
await allNodesTab.click()
await expect(essentialCards.first()).not.toBeVisible()
})
})

View File

@@ -0,0 +1,140 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
)
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
test('Escape closes search box without adding node', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.input).toHaveValue('')
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const samplingResults = await searchBoxV2.results.allTextContents()
await searchBoxV2.categoryButton('loaders').click()
await expect(searchBoxV2.results.first()).toBeVisible()
const loaderResults = await searchBoxV2.results.allTextContents()
expect(samplingResults).not.toEqual(loaderResults)
})
})
test.describe('Filter workflow', () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
// Get unfiltered count
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredCount = await searchBoxV2.results.count()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
await expect(searchBoxV2.results.first()).toBeVisible()
const filteredCount = await searchBoxV2.results.count()
expect(filteredCount).toBeLessThanOrEqual(unfilteredCount)
// Remove filter by pressing Backspace with empty input
await searchBoxV2.input.fill('')
await comfyPage.page.keyboard.press('Backspace')
// Results should restore to unfiltered count
await expect(searchBoxV2.results.first()).toBeVisible()
const restoredCount = await searchBoxV2.results.count()
expect(restoredCount).toBeGreaterThanOrEqual(filteredCount)
})
})
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
})
})
})

View File

@@ -0,0 +1,82 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Queue Notification Banners', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge(): Promise<void> }
page: { keyboard: { press(key: string): Promise<void> } }
command: { executeCommand(cmd: string): Promise<void> }
nextFrame(): Promise<void>
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Toast appears when prompt is queued', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
test('Error toast appears on failed execution', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const errorToast = comfyPage.page.locator(
'.p-toast-message.p-toast-message-error'
)
await expect(errorToast.first()).toBeVisible()
})
test('Error toast contains error description', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const errorToast = comfyPage.page.locator(
'.p-toast-message.p-toast-message-error'
)
await expect(errorToast.first()).toBeVisible()
await expect(errorToast.first()).not.toHaveText('')
})
test('Toast close button dismisses individual toast', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
const closeButton = comfyPage.page.locator('.p-toast-close-button').first()
await closeButton.click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
})
test('Multiple toasts can stack', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts).not.toHaveCount(0)
const count = await comfyPage.toast.getVisibleToastCount()
expect(count).toBeGreaterThanOrEqual(2)
})
test('All toasts can be cleared', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
await comfyPage.toast.closeToasts()
expect(await comfyPage.toast.getVisibleToastCount()).toBe(0)
})
})

View File

@@ -0,0 +1,113 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Properties panel opens with workflow overview', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
})
test('Properties panel shows node details on selection', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
})
test('Node title input is editable', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(propertiesPanel.panelTitle).toContainText('KSampler')
// Click on the title to enter edit mode
await propertiesPanel.panelTitle.click()
const titleInput = propertiesPanel.root.getByTestId('node-title-input')
await expect(titleInput).toBeVisible()
await titleInput.fill('My Custom Sampler')
await titleInput.press('Enter')
await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler')
})
test('Search box filters properties', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
test('Collapse all / Expand all toggles sections', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
// Select multiple nodes so collapse toggle button appears
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
const collapseButton = propertiesPanel.root.getByRole('button', {
name: 'Collapse all'
})
await expect(collapseButton).toBeVisible()
await collapseButton.click()
const expandButton = propertiesPanel.root.getByRole('button', {
name: 'Expand all'
})
await expect(expandButton).toBeVisible()
await expandButton.click()
// After expanding, the button label switches back to "Collapse all"
await expect(collapseButton).toBeVisible()
})
test('Properties panel can be closed', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
// Click the properties button again to close
await comfyPage.actionbar.propertiesButton.click()
await expect(propertiesPanel.root).toBeHidden()
})
})

View File

@@ -0,0 +1,79 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('@canvas Selection Rectangle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Ctrl+A selects all nodes', async ({ comfyPage }) => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(totalCount).toBeGreaterThan(0)
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
await comfyPage.vueNodes.clearSelection()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
test('Single click selects one node', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: ['Control']
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
})
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
await expect(checkpointNode).toHaveClass(/outline-node-component-outline/)
})
test('Drag-select rectangle selects multiple nodes', async ({
comfyPage
}) => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await comfyPage.page.mouse.move(10, 400)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(800, 600, { steps: 10 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(1)
})
})

View File

@@ -0,0 +1,121 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('bypass button toggles node bypass state', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
// Click bypass button to bypass the node
await comfyPage.page.locator('[data-testid="bypass-button"]').click()
await comfyPage.nextFrame()
await expect(nodeRef).toBeBypassed()
// Re-select the node to show toolbox again
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
// Click bypass button again to un-bypass
await comfyPage.page.locator('[data-testid="bypass-button"]').click()
await comfyPage.nextFrame()
await expect(nodeRef).not.toBeBypassed()
})
test('delete button removes selected node', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
await comfyPage.page.locator('[data-testid="delete-button"]').click()
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
await comfyPage.page.locator('[data-testid="info-button"]').click()
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
})
test('refresh button is visible when node is selected', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="refresh-button"]')
).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
).toBeVisible()
})
test('delete button removes multiple selected nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await comfyPage.nextFrame()
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
await comfyPage.page.locator('[data-testid="delete-button"]').click()
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 2)
})
})

View File

@@ -0,0 +1,57 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Settings Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Settings button is visible in sidebar', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await expect(settingsButton).toBeVisible()
})
test('Clicking settings button opens settings dialog', async ({
comfyPage
}) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
})
test('Settings dialog shows categories', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.categories.first()).toBeVisible()
expect(await comfyPage.settingDialog.categories.count()).toBeGreaterThan(0)
})
test('Settings dialog can be closed with Escape', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.settingDialog.root).not.toBeVisible()
})
test('Settings search box is functional', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.settingDialog.searchBox.fill('color')
await expect(comfyPage.settingDialog.searchBox).toHaveValue('color')
})
test('Settings dialog can navigate to About panel', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.settingDialog.goToAboutPanel()
await expect(comfyPage.page.locator('.about-container')).toBeVisible()
})
})

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Toast Notifications', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge(): Promise<void> }
page: { keyboard: { press(key: string): Promise<void> } }
command: { executeCommand(cmd: string): Promise<void> }
nextFrame(): Promise<void>
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error toast appears on execution failure', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
test('Toast shows correct error severity class', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const errorToast = comfyPage.page.locator(
'.p-toast-message.p-toast-message-error'
)
await expect(errorToast.first()).toBeVisible()
})
test('Toast can be dismissed via close button', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
const closeButton = comfyPage.page.locator('.p-toast-close-button').first()
await closeButton.click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
})
test('All toasts cleared via closeToasts helper', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
await comfyPage.toast.closeToasts()
expect(await comfyPage.toast.getVisibleToastCount()).toBe(0)
})
test('Toast error count is accurate', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-error').first()
).toBeVisible()
const errorCount = await comfyPage.toast.getToastErrorCount()
expect(errorCount).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -0,0 +1,69 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
test.describe('Vue Node Header Actions', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Collapse button is visible on node header', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.collapseButton).toBeVisible()
})
test('Clicking collapse button hides node body', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.body).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).not.toBeVisible()
})
test('Clicking collapse button again expands node', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).not.toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).toBeVisible()
})
test('Double-click header enters title edit mode', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.header.dblclick()
await expect(vueNode.titleInput).toBeVisible()
})
test('Title edit saves on Enter', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.setTitle('My Custom Sampler')
expect(await vueNode.getTitle()).toBe('My Custom Sampler')
})
test('Title edit cancels on Escape', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.setTitle('Renamed Sampler')
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
await vueNode.header.dblclick()
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
await comfyPage.nextFrame()
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
})
})

View File

@@ -0,0 +1,84 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('Widget copy button', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Vue nodes render with widgets', async ({ comfyPage }) => {
const nodeCount = await comfyPage.vueNodes.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
const firstNode = comfyPage.vueNodes.nodes.first()
await expect(firstNode).toBeVisible()
})
test('Textarea widgets exist on nodes', async ({ comfyPage }) => {
const textareas = comfyPage.page.locator('[data-node-id] textarea')
await expect(textareas.first()).toBeVisible()
expect(await textareas.count()).toBeGreaterThan(0)
})
test('Copy button has correct aria-label', async ({ comfyPage }) => {
const copyButtons = comfyPage.page.locator(
'[data-node-id] button[aria-label]'
)
const count = await copyButtons.count()
if (count > 0) {
const button = copyButtons.filter({
has: comfyPage.page.locator('.icon-\\[lucide--copy\\]')
})
if ((await button.count()) > 0) {
await expect(button.first()).toHaveAttribute('aria-label', /copy/i)
}
}
})
test('Copy icon uses lucide copy class', async ({ comfyPage }) => {
const copyIcons = comfyPage.page.locator(
'[data-node-id] .icon-\\[lucide--copy\\]'
)
const count = await copyIcons.count()
if (count > 0) {
await expect(copyIcons.first()).toBeVisible()
}
})
test('Widget container has group class for hover', async ({ comfyPage }) => {
const textareas = comfyPage.page.locator('[data-node-id] textarea')
const count = await textareas.count()
if (count > 0) {
const container = textareas.first().locator('..')
await expect(container).toHaveClass(/group/)
}
})
test('Copy button exists within textarea widget group container', async ({
comfyPage
}) => {
const groupContainers = comfyPage.page.locator('[data-node-id] div.group')
const count = await groupContainers.count()
if (count > 0) {
const container = groupContainers.first()
await container.hover()
await comfyPage.nextFrame()
const copyButton = container.locator('button').filter({
has: comfyPage.page.locator('.icon-\\[lucide--copy\\]')
})
if ((await copyButton.count()) > 0) {
await expect(copyButton.first()).toHaveClass(/invisible/)
}
}
})
})

View File

@@ -127,7 +127,7 @@ export default defineConfig([
// Off: may conflict with oxfmt formatting
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/enforce-consistent-class-order': 'error',
'better-tailwindcss/enforce-consistent-class-order': 'off',
'better-tailwindcss/enforce-canonical-classes': 'error',
'better-tailwindcss/no-deprecated-classes': 'error'
}

View File

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

View File

@@ -1,13 +1,13 @@
<template>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
class="absolute inset-0 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
<Loader size="lg" class="text-white" />
</div>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</template>
<script setup lang="ts">
@@ -15,7 +15,7 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import Loader from '@/components/common/Loader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -1,6 +1,6 @@
<template>
<div
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
class="size-full absolute top-0 left-0 z-999 pointer-events-none flex flex-col"
>
<slot name="workflow-tabs" />
@@ -17,7 +17,7 @@
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
@@ -30,10 +30,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -60,7 +60,7 @@
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
class="bg-transparent pointer-events-none border-none splitter-overlay-bottom mx-1 mb-1 flex-1"
layout="vertical"
:pt:gutter="
cn(
@@ -77,7 +77,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
class="bottom-panel border border-(--p-panel-border-color) max-w-full overflow-x-auto bg-comfy-menu-bg pointer-events-auto rounded-lg"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -92,10 +92,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -1,7 +1,7 @@
<template>
<div
v-show="workspaceState.focusMode"
class="no-drag fixed top-0 right-0 z-9999 flex flex-row"
class="fixed z-9999 flex flex-row no-drag top-0 right-0"
>
<Button
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"

View File

@@ -38,7 +38,7 @@
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'

View File

@@ -19,12 +19,12 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div class="relative flex items-center gap-2 select-none">
<div class="relative flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle h-max w-3 cursor-grab',
'drag-handle cursor-grab w-3 h-max',
isDragging && 'cursor-grabbing'
)
"
@@ -423,18 +423,18 @@ const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:-ml-50 before:h-full before:w-50',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
isMouseOverDropZone.value &&
'scale-105 border-[3px] opacity-100 shadow-[0_0_20px] shadow-blue-500'
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
)
)
const panelClass = computed(() =>
cn(
'actionbar pointer-events-auto z-1300',
isDragging.value && 'pointer-events-none select-none',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
? 'static border-none bg-transparent p-0'
? 'p-0 static border-none bg-transparent'
: 'fixed shadow-interface'
)
)

View File

@@ -42,7 +42,7 @@ function openTemplates() {
</script>
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<Button
@@ -53,7 +53,7 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
@@ -83,7 +83,7 @@ function openTemplates() {
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
class="flex flex-col w-10 rounded-lg bg-secondary-background overflow-hidden"
>
<Button
v-tooltip.right="{

View File

@@ -12,7 +12,7 @@
size="sm"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'pointer-events-none opacity-0 select-none': !isHovered
'opacity-0 pointer-events-none select-none': !isHovered
})
"
:aria-label="tooltipText"

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb -mt-4 flex w-auto items-center pt-4 drop-shadow-(--interface-panel-drop-shadow)"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -17,7 +17,7 @@
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
class="back-button pointer-events-auto size-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"

View File

@@ -204,7 +204,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
@@ -217,7 +217,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
@@ -228,7 +228,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
hidden-widget-actions
/>
</div>
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
<div v-else class="text-muted-foreground text-sm p-1 pointer-events-none">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
@@ -241,14 +241,14 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
@@ -271,7 +271,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
@@ -296,7 +296,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
@@ -319,8 +319,8 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
@@ -337,7 +337,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div
:class="
cn(
'pointer-events-auto absolute size-full',
'absolute size-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@@ -352,7 +352,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed rounded-lg bg-primary-background/30"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
@@ -362,7 +362,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
:style="toValue(style)"
:class="
cn(
'fixed rounded-2xl ring-5 ring-warning-background',
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
@@ -370,17 +370,17 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-warning-background p-2"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="bg-text-foreground icon-[lucide--check] size-full" />
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-component-node-background ring-4 ring-warning-background/50 ring-inset"
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'absolute top-[calc(var(--workflow-tabs-height)+16px)] left-4 z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg border-none py-2 pr-2 pl-3 shadow-interface transition-colors',
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
'bg-secondary-background hover:bg-secondary-background-hover',
'data-[state=open]:bg-secondary-background-hover'
)
@@ -22,10 +22,10 @@
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
: 'opacity-50 pointer-events-none'
)
"
:disabled="!hasOutputs"
@@ -36,7 +36,7 @@
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />

View File

@@ -13,7 +13,7 @@
stepClasses,
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'bg-transparent hover:bg-secondary-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@@ -32,7 +32,7 @@
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
@@ -48,7 +48,7 @@
stepClasses,
activeStep === 'setDefaultView'
? 'bg-interface-builder-mode-background'
: 'bg-transparent hover:bg-secondary-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="navigateToStep('setDefaultView')"

View File

@@ -7,7 +7,7 @@
side="bottom"
:side-offset="8"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
class="z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade"
>
<div class="flex h-12 items-center justify-between px-4">
<h3 class="text-sm font-medium text-base-foreground">

View File

@@ -32,13 +32,13 @@ const entries = computed(() => {
})
</script>
<template>
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
<div class="p-2 my-2 rounded-lg flex items-center-safe gap-2">
<div
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
class="mr-auto flex-[4_1_0%] max-w-max min-w-0 truncate drag-handle inline"
v-text="title"
/>
<div
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
class="flex-[2_1_0%] max-w-max min-w-0 truncate text-muted-foreground text-end drag-handle inline"
v-text="subTitle"
/>
<Popover :entries>

View File

@@ -4,7 +4,7 @@
{{ step.title }}
</span>
<span
class="hidden text-xs whitespace-nowrap text-muted-foreground sm:inline"
class="hidden whitespace-nowrap text-xs text-muted-foreground sm:inline"
>
{{ step.subtitle }}
</span>

View File

@@ -2,7 +2,7 @@
<div
:class="
cn(
'flex shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-secondary-background p-0 shadow-sm outline-hidden transition-all duration-200',
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
backgroundClass
)
"

View File

@@ -14,6 +14,6 @@ const { fullHeight = true } = defineProps<{
}>()
const containerClasses = computed(() =>
cn('w-full flex-1', fullHeight && 'h-full')
cn('flex-1 w-full', fullHeight && 'h-full')
)
</script>

View File

@@ -59,7 +59,7 @@ const containerClasses = computed(() => {
outline: cn(
hasBorder && 'border-2 border-border-subtle',
hasCursor && 'cursor-pointer',
'transition-colors hover:border-border-subtle/50'
'hover:border-border-subtle/50 transition-colors'
)
}

View File

@@ -19,9 +19,9 @@ const baseClasses =
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: cn('bg-base-background/50 text-base-foreground backdrop-blur-[2px]'),
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground'),
gray: cn(
'bg-modal-card-tag-background text-base-foreground backdrop-blur-[2px]'
'backdrop-blur-[2px] bg-modal-card-tag-background text-base-foreground'
)
}

View File

@@ -51,7 +51,7 @@ onBeforeUnmount(() => {
})
</script>
<template>
<div ref="draggableItems" class="mt-0.5 space-y-0.5 px-2 pb-2">
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>

View File

@@ -22,7 +22,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
<template>
<DropdownMenuSeparator
v-if="item.separator"
class="m-1 h-px bg-border-subtle"
class="h-px bg-border-subtle m-1"
/>
<DropdownMenuSub v-else-if="item.items">
<DropdownMenuSubTrigger
@@ -58,7 +58,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
{{ item.label }}
<div
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,14 +27,14 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-hover data-disabled:pointer-events-none data-disabled:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
itemProp
)
)
const contentClass = computed(() =>
cn(
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-1700 min-w-[220px] rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[opacity,transform]',
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
contentProp
)
)

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Loader from './Loader.vue'
const meta: Meta<typeof Loader> = {
title: 'Components/Loader/Loader',
title: 'Components/Common/Loader',
component: Loader,
tags: ['autodocs'],
parameters: {

View File

@@ -1,9 +1,9 @@
<template>
<div
class="@container overflow-hidden mask-[linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:mask-none"
class="overflow-hidden @container mask-[linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:mask-none"
>
<span
class="inline-block min-w-full text-center whitespace-nowrap [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:animate-[marquee-scroll_3s_linear_infinite_alternate]"
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:animate-[marquee-scroll_3s_linear_infinite_alternate]"
>
<slot />
</span>

View File

@@ -1,11 +1,11 @@
<template>
<span class="relative inline-flex size-[1em] items-center justify-center">
<span class="relative inline-flex items-center justify-center size-[1em]">
<i :class="mainIcon" class="text-[1em]" />
<i
:class="
cn(
subIcon,
'pointer-events-none absolute leading-none',
'absolute leading-none pointer-events-none',
positionX === 'left' ? 'left-0' : 'right-0',
positionY === 'top' ? 'top-0' : 'bottom-0'
)

View File

@@ -8,7 +8,7 @@
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@@ -16,7 +16,7 @@
>
<i class="pi pi-minus" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
@@ -24,7 +24,7 @@
:disabled
:class="
cn(
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
@@ -53,7 +53,7 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"

View File

@@ -2,7 +2,7 @@
<div
:class="
cn(
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
'relative flex w-full items-center gap-2 bg-comfy-input cursor-text text-comfy-input-foreground',
customClass,
wrapperStyle
)
@@ -16,7 +16,7 @@
unstyled
:class="
cn(
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
'absolute inset-0 size-full border-none outline-none bg-transparent text-sm',
isLarge ? 'pl-11' : 'pl-8'
)
"
@@ -26,7 +26,7 @@
v-if="filterIcon"
size="icon"
variant="textonly"
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
class="filter-button absolute right-0 inset-y-0 m-0 p-0"
@click="$emit('showFilter', $event)"
>
<i :class="filterIcon" />
@@ -117,7 +117,7 @@ watchDebounced(
const wrapperStyle = computed(() => {
if (showBorder) {
return cn(
'box-border rounded-sm border border-solid border-border-default p-2',
'rounded-sm p-2 border border-solid border-border-default box-border',
isLarge.value ? 'h-10' : 'h-8'
)
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-auto flex-col gap-2">
<div class="flex flex-col gap-2 flex-auto">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
@@ -7,7 +7,7 @@
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'box-border border border-solid border-border-default',
'border border-solid border-border-default box-border',
sizeClasses,
className
)
@@ -15,7 +15,7 @@
>
<i
v-if="!searchTerm"
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
:class="cn('absolute left-4 pointer-events-none', icon, iconClass)"
/>
<Button
v-else

View File

@@ -3,7 +3,7 @@
<!-- Hidden single-line measurement element for overflow detection -->
<div
ref="measureRef"
class="pointer-events-none invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap"
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
aria-hidden="true"
>
<slot />
@@ -13,7 +13,7 @@
<slot />
</MarqueeLine>
<div v-else class="flex w-full flex-col">
<div v-else class="flex flex-col w-full">
<MarqueeLine>{{ firstLine }}</MarqueeLine>
<MarqueeLine>{{ secondLine }}</MarqueeLine>
</div>

View File

@@ -3,7 +3,7 @@
v-bind="$attrs"
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer bg-transparent px-2 py-0 2xl:px-4"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="props.class"
:value="renderedRoot.children"
selection-mode="single"

View File

@@ -29,7 +29,7 @@
/>
</div>
<div
class="node-actions flex gap-1 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100 touch:opacity-100"
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

View File

@@ -42,7 +42,7 @@
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-highlight focus:bg-highlight"
@select="handleAddToFavorites"
>
<i

View File

@@ -19,7 +19,7 @@
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
@@ -27,7 +27,7 @@
<button
:class="
cn(
'hover:text-foreground flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
@@ -64,7 +64,7 @@
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="folder" :node="item.value">
{{ item.value.label }}
</slot>

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">

View File

@@ -55,7 +55,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="pointer-events-auto relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -79,7 +79,7 @@ function handleOpen(open: boolean) {
:align
:side-offset="5"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
>
<WorkflowActionsList :items="menuItems" />
</DropdownMenuContent>

View File

@@ -22,7 +22,7 @@ const {
<component
:is="separatorComponent"
v-if="item.separator"
class="my-1 w-full border-b border-border-subtle"
class="border-b w-full border-border-subtle my-1"
/>
<component
:is="itemComponent"
@@ -30,11 +30,11 @@ const {
:disabled="item.disabled"
:class="
cn(
'flex min-h-6 items-center gap-2 self-stretch rounded-sm p-2 outline-none',
'flex min-h-6 p-2 items-center gap-2 self-stretch rounded-sm outline-none',
!item.disabled && item.command && 'cursor-pointer',
'data-highlighted:bg-secondary-background-hover',
!item.disabled && 'hover:bg-secondary-background-hover',
'data-disabled:cursor-default data-disabled:opacity-50'
'data-disabled:opacity-50 data-disabled:cursor-default'
)
"
@select="() => item.command?.()"
@@ -44,7 +44,7 @@ const {
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.badge"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="rounded-full uppercase ml-3 flex items-center gap-1 bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground"
>
{{ item.badge }}
</span>

View File

@@ -15,30 +15,25 @@ export function createMonotoneInterpolator(
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = new Float64Array(n)
const ys = new Float64Array(n)
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
for (let i = 0; i < n; i++) {
xs[i] = sorted[i][0]
ys[i] = sorted[i][1]
}
const deltas = new Float64Array(n - 1)
const slopes = new Float64Array(n)
const deltas: number[] = []
const slopes: number[] = []
for (let i = 0; i < n - 1; i++) {
const dx = xs[i + 1] - xs[i]
deltas[i] = dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
}
slopes[0] = deltas[0] ?? 0
slopes.push(deltas[0] ?? 0)
for (let i = 1; i < n - 1; i++) {
if (deltas[i - 1] * deltas[i] <= 0) {
slopes[i] = 0
slopes.push(0)
} else {
slopes[i] = (deltas[i - 1] + deltas[i]) / 2
slopes.push((deltas[i - 1] + deltas[i]) / 2)
}
}
slopes[n - 1] = deltas[n - 2] ?? 0
slopes.push(deltas[n - 2] ?? 0)
for (let i = 0; i < n - 1; i++) {
if (deltas[i] === 0) {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border-t border-border-default px-4 py-2 text-sm wrap-break-word text-muted-foreground"
class="flex flex-col wrap-break-word px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}

View File

@@ -1,5 +1,5 @@
<template>
<section class="flex w-full flex-wrap justify-end gap-2 px-2 pb-2">
<section class="w-full flex flex-wrap gap-2 justify-end px-2 pb-2">
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
{{ cancelTextX }}
</Button>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
>
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<section
class="m-2 mt-4 flex flex-col gap-6 wrap-break-word whitespace-pre-wrap"
class="m-2 mt-4 flex flex-col gap-6 whitespace-pre-wrap wrap-break-word"
>
<div>
<span>{{ message }}</span>
@@ -40,12 +40,12 @@
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-8 text-sm text-muted-foreground"
class="text-sm text-muted-foreground ml-8"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openBlueprintOverwriteSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}

View File

@@ -8,7 +8,7 @@
</p>
<div
class="flex scrollbar-custom max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background"
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="model in processedModels"
@@ -17,13 +17,13 @@
>
<div class="flex items-center gap-2 overflow-hidden">
<span
class="text-foreground min-w-0 truncate text-sm"
class="min-w-0 truncate text-sm text-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold text-muted-foreground uppercase"
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
>
{{ model.badgeLabel }}
</span>
@@ -81,7 +81,7 @@
</div>
</div>
<p class="m-0 text-xs/5 whitespace-pre-line text-muted-foreground">
<p class="m-0 text-xs/5 text-muted-foreground whitespace-pre-line">
{{ $t('missingModelsDialog.footerDescription') }}
</p>
@@ -90,7 +90,7 @@
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
class="icon-[lucide--triangle-alert] mt-0.5 size-4 shrink-0 text-warning-background"
/>
<div class="flex flex-col gap-1">
<p class="m-0 text-xs/5 font-semibold text-warning-background">

View File

@@ -23,7 +23,7 @@
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-primary uppercase">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="size-2 rounded-full bg-primary" />
@@ -35,7 +35,7 @@
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="mr-1.5 icon-[lucide--refresh-cw] size-4" />
<i class="icon-[lucide--refresh-cw] mr-1.5 size-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
@@ -46,7 +46,7 @@
<!-- Replaceable nodes list -->
<div
class="flex scrollbar-custom max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background"
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<!-- Select All row (sticky header) -->
<div
@@ -55,7 +55,7 @@
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
@@ -77,14 +77,14 @@
>
<i
v-if="isAllSelected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="text-bold icon-[lucide--minus] text-xs text-base-foreground"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium text-muted-foreground uppercase">
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
@@ -97,7 +97,7 @@
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'pointer-events-none opacity-50'
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
@@ -124,24 +124,24 @@
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="border-success bg-success/10 text-success inline-flex h-4 items-center rounded-full border px-1.5 text-xxxs font-semibold uppercase"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold text-primary uppercase"
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-foreground text-sm">
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
@@ -160,7 +160,7 @@
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-error uppercase">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
@@ -168,7 +168,7 @@
<!-- Non-replaceable nodes list -->
<div
class="flex scrollbar-custom flex-col overflow-y-auto rounded-lg bg-secondary-background"
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
@@ -179,11 +179,11 @@
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold text-error uppercase"
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-foreground text-sm">
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
@@ -209,9 +209,9 @@
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="mt-0.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
class="icon-[lucide--triangle-alert] mt-0.5 size-4 shrink-0 text-warning-background"
/>
<p class="text-neutral-foreground m-0 text-xs/5">
<p class="m-0 text-xs/5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full flex-col gap-2 px-4 py-2">
<div class="flex w-full flex-col gap-2 py-2 px-4">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
@@ -16,12 +16,12 @@
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-6 text-sm text-muted-foreground"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingNodesSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}

View File

@@ -3,8 +3,8 @@
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex items-center justify-between p-8">
<h2 class="m-0 text-lg font-bold text-base-foreground">
<div class="flex p-8 items-center justify-between">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
@@ -12,7 +12,7 @@
}}
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
@@ -20,7 +20,7 @@
</div>
<p
v-if="isInsufficientCredits"
class="m-0 px-8 text-sm text-muted-foreground"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
@@ -39,7 +39,7 @@
size="lg"
:class="
cn(
'focus-visible:ring-secondary-foreground h-10 w-full text-base font-medium',
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@@ -95,7 +95,7 @@
<p
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
@@ -106,7 +106,7 @@
</p>
<p
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
@@ -123,7 +123,7 @@
>
</p>
<div class="flex flex-col gap-8 p-8">
<div class="p-8 flex flex-col gap-8">
<Button
:disabled="!isValidAmount || loading"
:loading="loading"

View File

@@ -1,10 +1,10 @@
<template>
<div
class="flex cursor-pointer items-center justify-between rounded-lg p-2 transition-all duration-200"
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'border-2 border-border-default bg-secondary-background'
: 'bg-component-node-disabled border-2 border-transparent hover:bg-secondary-background'
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
]"
@click="$emit('select')"
>

View File

@@ -67,7 +67,7 @@
v-if="!isApiKeyLogin"
keypath="auth.deleteAccount.contactSupport"
tag="p"
class="text-sm text-muted"
class="text-muted text-sm"
>
<template #email>
<a href="mailto:support@comfy.org" class="underline"

View File

@@ -10,7 +10,7 @@
{{ $t('subscription.cancelDialog.title') }}
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
:disabled="isLoading"
@click="onClose"

View File

@@ -4,7 +4,7 @@
enter-from-class="-translate-y-3 opacity-0"
enter-to-class="translate-y-0 opacity-100"
>
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
<div
role="alert"
aria-live="assertive"
@@ -32,12 +32,12 @@
<li
v-for="(message, idx) in groupedErrorMessages"
:key="idx"
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
class="flex items-baseline gap-2 text-sm/snug text-muted-foreground min-w-0"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span class="line-clamp-3 wrap-break-word whitespace-pre-wrap">{{
<span class="wrap-break-word line-clamp-3 whitespace-pre-wrap">{{
message
}}</span>
</li>

View File

@@ -74,7 +74,7 @@ const pressed = ref(false)
<SliderThumb
:class="
cn(
'top-1/2 block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25',
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25 top-1/2',
'transition-[color,box-shadow,background-color]',
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',

View File

@@ -6,7 +6,7 @@
<template v-if="showUI" #workflow-tabs>
<div
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
class="workflow-tabs-container pointer-events-auto relative w-full h-(--workflow-tabs-height)"
>
<!-- Native drag area for Electron -->
<div

View File

@@ -2,7 +2,7 @@
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] overflow-y-auto md:max-h-none md:overflow-y-visible"
class="max-h-[80vh] md:max-h-none overflow-y-auto md:overflow-y-visible"
@show="onMenuShow"
@hide="onMenuHide"
>

View File

@@ -23,7 +23,7 @@
:class="
isColorSubmenu
? 'flex flex-col gap-1 p-2'
: 'flex min-w-40 flex-col p-2'
: 'flex flex-col p-2 min-w-40'
"
>
<div
@@ -31,12 +31,12 @@
:key="subOption.label"
:class="
cn(
'cursor-pointer rounded-sm hover:bg-secondary-background-hover',
'hover:bg-secondary-background-hover rounded-sm cursor-pointer',
isColorSubmenu
? 'flex size-7 items-center justify-center'
? 'size-7 flex items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
subOption.disabled
? 'pointer-events-none cursor-not-allowed text-node-icon-disabled'
? 'cursor-not-allowed pointer-events-none text-node-icon-disabled'
: 'hover:bg-secondary-background-hover'
)
"

View File

@@ -33,7 +33,7 @@
<span class="menu-label">{{ menuItem.label }}</span>
<i
v-if="menuItem.showExternalIcon"
class="ml-auto icon-[lucide--external-link] size-4 text-primary"
class="icon-[lucide--external-link] text-primary size-4 ml-auto"
/>
<i
v-if="menuItem.key === 'more'"

View File

@@ -31,10 +31,10 @@ function toggle() {
<div
:class="
cn(
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
: 'max-h-0 w-0'
? 'w-full max-h-100 sm:w-[max(400px,40vw)]'
: 'w-0 max-h-0'
)
"
>

View File

@@ -16,7 +16,7 @@
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex h-10 cursor-pointer select-none',
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
@@ -24,7 +24,7 @@
? 'border-node-component-border'
: 'border-transparent',
'focus-within:border-node-component-border',
{ 'cursor-default opacity-60': props.disabled }
{ 'opacity-60 cursor-default': props.disabled }
)
}),
labelContainer: {
@@ -62,7 +62,7 @@
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'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 &&
@@ -150,7 +150,7 @@
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
class="flex items-center gap-2 cursor-pointer"
:style="popoverStyle"
>
<div

View File

@@ -40,7 +40,7 @@
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'mt-2 p-2 rounded-lg',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
@@ -57,7 +57,7 @@
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'flex items-center justify-between gap-3 px-2 py-3 rounded-sm',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',

View File

@@ -1,5 +1,5 @@
<template>
<div class="show-slider relative">
<div class="relative show-slider">
<Button
v-tooltip.right="{ value: tooltipText, showDelay: 300 }"
size="icon"
@@ -12,7 +12,7 @@
</Button>
<div
v-show="showSlider"
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
>
<Slider
v-model="value"

View File

@@ -13,7 +13,7 @@
:class="
cn(
'rounded-full',
isRecording && 'recording-button-blink text-red-500'
isRecording && 'text-red-500 recording-button-blink'
)
"
:aria-label="

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -1,100 +0,0 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoComfyWaveLoader from './LogoComfyWaveLoader.vue'
const meta: Meta<typeof LogoComfyWaveLoader> = {
title: 'Components/Loader/LogoComfyWaveLoader',
component: LogoComfyWaveLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#F0FF41 (Yellow)</span>
<LogoComfyWaveLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#172DD7 (Blue)</span>
<LogoComfyWaveLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoComfyWaveLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoComfyWaveLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-8">
<LogoComfyWaveLoader size="sm" color="yellow" />
<LogoComfyWaveLoader size="md" color="yellow" />
<LogoComfyWaveLoader size="lg" color="yellow" />
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
`
})
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3 class="text-descrip-text mt-2.5 text-center font-sans text-[15px]">
<h3 class="text-center text-[15px] font-sans text-descrip-text mt-2.5">
{{ t('maskEditor.brushSettings') }}
</h3>
@@ -10,19 +10,19 @@
<!-- Brush Shape -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.brushShape') }}
</span>
<div
class="flex h-[50px] w-full flex-row items-center gap-2.5 rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
>
<div
class="maskEditor_sidePanelBrushShapeCircle hover:bg-comfy-menu-bg"
:class="
cn(
store.brushSettings.type === BrushShape.Arc
? 'active bg-(--p-button-text-primary-color)'
? 'bg-(--p-button-text-primary-color) active'
: 'bg-transparent'
)
"
@@ -34,7 +34,7 @@
:class="
cn(
store.brushSettings.type === BrushShape.Rect
? 'active bg-(--p-button-text-primary-color)'
? 'bg-(--p-button-text-primary-color) active'
: 'bg-transparent'
)
"
@@ -45,27 +45,27 @@
<!-- Color -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.colorSelector') }}
</span>
<input
ref="colorInputRef"
v-model="store.rgbColor"
type="color"
class="h-10 cursor-pointer rounded-md"
class="h-10 rounded-md cursor-pointer"
/>
</div>
<!-- Thickness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.thickness') }}
</span>
<input
v-model.number="brushSize"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="250"
:step="1"
@@ -84,13 +84,13 @@
<!-- Opacity -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.opacity') }}
</span>
<input
v-model.number="brushOpacity"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
@@ -109,13 +109,13 @@
<!-- Hardness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.hardness') }}
</span>
<input
v-model.number="brushHardness"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
@@ -134,13 +134,13 @@
<!-- Step Size -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-descrip-text text-left font-sans text-xs">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.stepSize') }}
</span>
<input
v-model.number="brushStepSize"
type="number"
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="100"
:step="1"

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3 class="mt-2.5 text-center font-sans text-[15px] text-(--descrip-text)">
<h3 class="text-center text-[15px] font-sans text-(--descrip-text) mt-2.5">
{{ t('maskEditor.colorSelectSettings') }}
</h3>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3 class="mt-2.5 text-center font-sans text-[15px] text-(--descrip-text)">
<h3 class="text-center text-[15px] font-sans text-(--descrip-text) mt-2.5">
{{ t('maskEditor.layers') }}
</h3>
@@ -13,12 +13,12 @@
@update:model-value="onMaskOpacityChange"
/>
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
t('maskEditor.maskBlendingOptions')
}}</span>
<div
class="relative -mt-2 -mb-1.5 flex h-[50px] min-h-6 w-full flex-row items-center gap-2.5 rounded-[10px]"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
>
<select
class="maskEditor_sidePanelDropdown"
@@ -31,11 +31,11 @@
</select>
</div>
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
t('maskEditor.maskLayer')
}}</span>
<div
class="relative flex h-[50px] min-h-6 w-full flex-row items-center gap-2.5 rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
:style="{
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
}"
@@ -64,11 +64,11 @@
</button>
</div>
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
t('maskEditor.paintLayer')
}}</span>
<div
class="relative flex h-[50px] min-h-6 w-full flex-row items-center gap-2.5 rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
:style="{
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
}"
@@ -104,11 +104,11 @@
</button>
</div>
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
t('maskEditor.baseImageLayer')
}}</span>
<div
class="relative flex h-[50px] min-h-6 w-full flex-row items-center gap-2.5 rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
>
<input
type="checkbox"

View File

@@ -13,29 +13,29 @@
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 z-0 size-full"
class="absolute top-0 left-0 size-full z-0"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 z-10 size-full"
class="absolute top-0 left-0 size-full z-10"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 z-30 size-full"
class="absolute top-0 left-0 size-full z-30"
@contextmenu.prevent
/>
<!-- GPU Preview Canvas -->
<canvas
ref="gpuCanvasRef"
class="pointer-events-none absolute top-0 left-0 size-full"
class="absolute top-0 left-0 size-full pointer-events-none"
:class="{
'z-20': store.activeLayer === 'rgb',
'z-40': store.activeLayer === 'mask'
}"
/>
<div ref="canvasBackgroundRef" class="size-full bg-white" />
<div ref="canvasBackgroundRef" class="bg-white size-full" />
</div>
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3 class="mt-2.5 text-center font-sans text-[15px] text-(--descrip-text)">
<h3 class="text-center text-[15px] font-sans text-(--descrip-text) mt-2.5">
{{ t('maskEditor.paintBucketSettings') }}
</h3>

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="pointerZoneRef"
class="h-full w-[calc(100%-4rem-220px)]"
class="w-[calc(100%-4rem-220px)] h-full"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"

View File

@@ -1,11 +1,11 @@
<template>
<div
class="flex h-full w-55 flex-col items-stretch! gap-3 overflow-y-auto bg-(--comfy-menu-bg) px-2.5 pb-3"
class="flex flex-col gap-3 pb-3 h-full items-stretch! bg-(--comfy-menu-bg) overflow-y-auto w-55 px-2.5"
>
<div class="min-h-full w-full">
<div class="w-full min-h-full">
<SettingsPanelContainer />
<div class="mt-6 mb-1.5 h-0.5 w-full bg-(--border-color)" />
<div class="w-full h-0.5 bg-(--border-color) mt-6 mb-1.5" />
<ImageLayerSettingsPanel :tool-manager="toolManager" />
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="z-8888 flex h-full flex-col justify-between bg-comfy-menu-bg">
<div class="h-full z-8888 flex flex-col justify-between bg-comfy-menu-bg">
<div class="flex flex-col">
<div
v-for="tool in allTools"
@@ -19,7 +19,7 @@
</div>
<div
class="mb-2 flex cursor-pointer flex-col items-center rounded-md transition-colors duration-200 hover:bg-secondary-background-hover"
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-secondary-background-hover"
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>

View File

@@ -1,10 +1,10 @@
<template>
<div class="relative flex min-h-6 flex-row items-center gap-2.5">
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
label
}}</span>
<select
class="absolute right-0 h-6 rounded-md border border-border-default bg-secondary-background px-1.5 transition-colors duration-100 focus:outline focus:outline-node-component-border"
class="absolute right-0 h-6 px-1.5 rounded-md border border-border-default transition-colors duration-100 bg-secondary-background focus:outline focus:outline-node-component-border"
:value="modelValue"
@change="onChange"
>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
label
}}</span>
<input

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative flex min-h-6 flex-row items-center gap-2.5">
<span class="text-left font-sans text-xs text-(--descrip-text)">{{
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-(--descrip-text)">{{
label
}}</span>
<label class="maskEditor_sidePanelToggleContainer">

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-current"
class="h-6.25 w-6.25 pointer-events-none fill-current"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -26,7 +26,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="h-6.25 w-6.25 pointer-events-none fill-(--input-text)"
>
<path
class="cls-1"
@@ -35,7 +35,7 @@
</svg>
</button>
<div class="border-border h-5 border-l" />
<div class="h-5 border-l border-border" />
<button
:class="iconButtonClass"
@@ -44,7 +44,7 @@
>
<svg
viewBox="-6 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="h-6.25 w-6.25 pointer-events-none fill-(--input-text)"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
@@ -59,7 +59,7 @@
>
<svg
viewBox="-9 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="h-6.25 w-6.25 pointer-events-none fill-(--input-text)"
>
<g transform="scale(-1, 1)">
<path
@@ -76,7 +76,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="h-6.25 w-6.25 pointer-events-none fill-(--input-text)"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
@@ -92,7 +92,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="h-6.25 w-6.25 pointer-events-none fill-(--input-text)"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -11,14 +11,14 @@
<!-- Content Section -->
<div class="flex flex-col gap-2 p-3 pt-1">
<!-- Title -->
<h3 class="text-foreground m-0 text-xs font-semibold">
<h3 class="text-xs font-semibold text-foreground m-0">
{{ nodeDef.display_name }}
</h3>
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 text-xs text-muted-foreground"
class="text-xs text-muted-foreground -mt-1"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</p>
@@ -32,7 +32,7 @@
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="text-[11px] font-normal leading-normal text-muted-foreground m-0"
>
{{ nodeDef.description }}
</p>
@@ -49,7 +49,7 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="text-xxs font-semibold uppercase tracking-wide text-muted-foreground m-0"
>
{{ $t('nodeHelpPage.inputs') }}
</h4>
@@ -58,7 +58,7 @@
:key="input.name"
class="flex items-center justify-between gap-2 text-xxs"
>
<span class="text-foreground shrink-0">{{ input.name }}</span>
<span class="shrink-0 text-foreground">{{ input.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
input.type
}}</span>
@@ -71,7 +71,7 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="text-xxs font-semibold uppercase tracking-wide text-muted-foreground m-0"
>
{{ $t('nodeHelpPage.outputs') }}
</h4>
@@ -80,7 +80,7 @@
:key="output.name"
class="flex items-center justify-between gap-2 text-xxs"
>
<span class="text-foreground shrink-0">{{ output.name }}</span>
<span class="shrink-0 text-foreground">{{ output.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
output.type
}}</span>

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