Compare commits

..

1 Commits

Author SHA1 Message Date
Arjan Singh
c12ae2390c [ci] add claude review experts 2025-09-12 19:55:04 -07:00
67 changed files with 1279 additions and 1630 deletions

View File

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

View File

@@ -47,7 +47,6 @@ jobs:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -70,17 +69,19 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@main
with:
label_trigger: "claude-review"
prompt: |
direct_prompt: |
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
DO NOT create a summary comment.
Each issue must be posted as a separate inline comment on the specific line of code.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--max-turns 256 --allowedTools 'Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch'"
max_turns: 256
timeout_minutes: 30
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: PR Playwright Deploy (Forks)
name: PR Playwright Deploy and Comment
on:
workflow_run:
@@ -9,84 +9,272 @@ env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
deploy-reports:
runs-on: ubuntu-latest
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed'
permissions:
pull-requests: write
actions: read
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Log workflow trigger info
run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v4
- name: Get PR Number
id: pr
- name: Get PR info
id: pr-info
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return { number: null, sanitized_branch: null };
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
const pr = pullRequests[0];
const branchName = context.payload.workflow_run.head_branch;
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
return {
number: pr.number,
sanitized_branch: sanitizedBranch
};
- name: Handle Test Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Set project name
if: fromJSON(steps.pr-info.outputs.result).number != null
id: project-name
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
else
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
fi
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
- name: Download playwright report
if: fromJSON(steps.pr-info.outputs.result).number != null
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
name: playwright-report-${{ matrix.browser }}
path: playwright-report
- name: Install Wrangler
if: fromJSON(steps.pr-info.outputs.result).number != null
run: npm install -g wrangler
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
if: fromJSON(steps.pr-info.outputs.result).number != null
id: cloudflare-deploy
continue-on-error: true
run: |
# Retry logic for wrangler deploy (3 attempts)
RETRY_COUNT=0
MAX_RETRIES=3
SUCCESS=false
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
SUCCESS=true
echo "Deployment successful on attempt $RETRY_COUNT"
else
echo "Deployment failed on attempt $RETRY_COUNT"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "Retrying in 10 seconds..."
sleep 10
fi
fi
done
if [ $SUCCESS = false ]; then
echo "All deployment attempts failed"
exit 1
fi
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
comment-tests-starting:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for start
if: steps.pr.outputs.result != 'null'
id: comment-body-start
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
echo "<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Tests are starting...** " >> comment.md
echo "" >> comment.md
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 🚀 Running Tests" >> comment.md
echo "- 🧪 **chromium**: Running tests..." >> comment.md
echo "- 🧪 **chromium-0.5x**: Running tests..." >> comment.md
echo "- 🧪 **chromium-2x**: Running tests..." >> comment.md
echo "- 🧪 **mobile-chrome**: Running tests..." >> comment.md
echo "" >> comment.md
echo "---" >> comment.md
echo "⏱️ Please wait while tests are running across all browsers..." >> comment.md
- name: Comment PR - Tests Started
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
comment-tests-completed:
runs-on: ubuntu-latest
needs: deploy-reports
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' && always()
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Download all deployment info
if: steps.pr.outputs.result != 'null'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: deployment-info-*
merge-multiple: true
path: deployment-info
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for completion
if: steps.pr.outputs.result != 'null'
id: comment-body-completed
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
# Check if all tests passed
ALL_PASSED=true
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
if [ "$exit_code" != "0" ]; then
ALL_PASSED=false
break
fi
fi
done
if [ "$ALL_PASSED" = "true" ]; then
echo "✅ **All tests passed across all browsers!**" >> comment.md
else
echo "❌ **Some tests failed!**" >> comment.md
fi
echo "" >> comment.md
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 📊 Test Reports by Browser" >> comment.md
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
url=$(echo "$info" | cut -d'|' -f3)
# Validate URLs before using them in comments
sanitized_url=$(echo "$url" | grep -E '^https://[a-z0-9.-]+\.pages\.dev(/.*)?$' || echo "INVALID_URL")
if [ "$sanitized_url" = "INVALID_URL" ]; then
echo "Invalid deployment URL detected: $url"
url="#" # Use safe fallback
fi
if [ "$exit_code" = "0" ]; then
status="✅"
else
status="❌"
fi
echo "- $status **$browser**: [View Report]($url)" >> comment.md
fi
done
echo "" >> comment.md
echo "---" >> comment.md
if [ "$ALL_PASSED" = "true" ]; then
echo "🎉 Your tests are passing across all browsers!" >> comment.md
else
echo "⚠️ Please check the test reports for details on failures." >> comment.md
fi
- name: Comment PR - Tests Complete
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md

View File

@@ -284,65 +284,3 @@ jobs:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
retention-days: 30
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -149,7 +149,7 @@ test.describe('Selection Toolbox', () => {
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(await selectedNode.getProperty('color')).not.toBeNull()
expect(selectedNode.getProperty('color')).not.toBeNull()
})
test('color picker shows current color of selected nodes', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,60 @@
---
name: frontend-a11y-reviewer
description: Use this agent when you need to review frontend code for accessibility compliance and best practices. Examples: <example>Context: The user has just implemented a new form component and wants to ensure it meets accessibility standards. user: 'I just created a contact form component with email, name, and message fields. Can you review it for accessibility?' assistant: 'I'll use the frontend-a11y-reviewer agent to analyze your form component for accessibility compliance.' <commentary>Since the user is requesting accessibility review of frontend code, use the frontend-a11y-reviewer agent to perform a comprehensive a11y audit.</commentary></example> <example>Context: The user has completed a modal dialog implementation and wants accessibility feedback. user: 'Here's my modal dialog code - please check if it's accessible' assistant: 'Let me review your modal dialog using the frontend-a11y-reviewer agent to ensure it meets accessibility standards.' <commentary>The user is asking for accessibility review of a modal component, so use the frontend-a11y-reviewer agent to check for proper focus management, ARIA attributes, and keyboard navigation.</commentary></example>
tools: Bash, Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, BashOutput, KillBash
model: sonnet
color: blue
---
You are an expert frontend accessibility reviewer specializing in identifying and resolving accessibility issues in web applications. Your expertise encompasses WCAG guidelines, ARIA specifications, semantic HTML, keyboard navigation, screen reader compatibility, and modern accessibility best practices.
When reviewing code for accessibility:
1. **Comprehensive Analysis**: Examine the provided code against the A11Y Project checklist (https://www.a11yproject.com/checklist/) and WCAG 2.1 AA standards. Focus on:
- Semantic HTML structure and proper element usage
- ARIA attributes and roles implementation
- Keyboard navigation and focus management
- Color contrast and visual accessibility
- Form accessibility (labels, error handling, validation)
- Interactive element accessibility
- Screen reader compatibility
- Alternative text for images and media
2. **Structured Review Process**:
- Identify all accessibility violations, from critical to minor
- Categorize issues by severity (Critical, High, Medium, Low)
- Provide specific, actionable recommendations for each issue
- Include code examples showing both problematic and corrected implementations
- Reference relevant WCAG success criteria and A11Y Project guidelines
3. **Documentation Requirements**:
- Create a comprehensive findings document in ~/code/claude-docs/
- Use filename pattern: `{repo-name}-a11y-review-{timestamp}.md`
- Structure the document with:
- Executive summary of accessibility status
- Detailed findings categorized by severity
- Specific code recommendations with before/after examples
- Testing recommendations for validation
- Resources for further learning
4. **Todo List Generation**:
- Provide a prioritized todo list for the main Claude session
- Order items by accessibility impact and implementation complexity
- Include estimated effort levels and dependencies
- Suggest testing methods for each fix
5. **Code-Specific Focus Areas**:
- Form controls: proper labeling, error messaging, validation feedback
- Interactive elements: buttons, links, custom controls
- Navigation: landmarks, headings hierarchy, skip links
- Dynamic content: live regions, state changes, loading states
- Media: alternative text, captions, transcripts
- Color and contrast: sufficient ratios, color-independent information
6. **Quality Assurance**:
- Cross-reference findings against multiple accessibility standards
- Provide testing recommendations using tools like axe, WAVE, or screen readers
- Include manual testing steps for keyboard and screen reader users
- Suggest automated testing integration where applicable
Always approach reviews with empathy for users with disabilities, understanding that accessibility is not just compliance but creating inclusive experiences. Provide clear, actionable guidance that developers can implement immediately while building long-term accessibility awareness.

View File

@@ -0,0 +1,71 @@
---
name: typescript-type-reviewer
description: Use this agent when you need to review TypeScript code for type errors, analyze type safety issues, and identify areas for improvement. This agent provides comprehensive analysis and creates actionable todo lists for type fixes. Examples: <example>Context: User has TypeScript compilation errors that need analysis. user: 'I'm getting type errors in my React component - can you review what's wrong?' assistant: 'I'll use the typescript-type-reviewer agent to analyze these type errors and provide detailed feedback with a todo list for fixes' <commentary>Since there are TypeScript type errors that need analysis, use the typescript-type-reviewer agent to review and provide guidance.</commentary></example> <example>Context: User wants to understand type safety issues in their codebase. user: 'Can you review this file and tell me what type safety issues exist?' assistant: 'I'll use the typescript-type-reviewer agent to analyze the type safety and provide recommendations' <commentary>The user wants comprehensive type safety analysis and recommendations.</commentary></example>
model: sonnet
color: blue
---
You are a TypeScript Type Safety Reviewer, specializing in analyzing and reviewing TypeScript code for type errors, safety issues, and improvement opportunities. Your mission is to provide comprehensive feedback, identify problems, and create actionable todo lists for fixing type issues.
Core Principles:
- CRITICAL: Treat 'any' as forbidden in recommendations. Identify all 'any' usage and provide alternatives
- Runtime errors are unacceptable - identify patterns that could lead to runtime failures
- Always recommend proper type definitions over type assertions ('as')
- When type assertions are unavoidable, clearly explain why and flag for explicit approval
Your Review Process:
1. **Analyze the Codebase**: Examine TypeScript code thoroughly for:
- Compilation errors and their root causes
- 'any' type usage and unsafe patterns
- Missing type definitions and interfaces
- Improper generic usage or constraints
- Type assertions that could be avoided
2. **Identify Root Causes**: Trace issues to their source:
- Missing or incorrect type definitions
- Inadequate interface design
- Poor generic constraints
- External library integration issues
- Legacy code patterns that bypass type safety
3. **Create Review Findings**: Document specific issues with:
- Error location and description
- Root cause analysis
- Recommended solution approach
- Priority level (critical/high/medium/low)
- Implementation complexity assessment
4. **Generate Action Plan**: Create structured todo list for fixes:
- Prioritized list of type issues to address
- Specific implementation recommendations
- Dependencies between fixes (what must be done first)
- Risk assessment for each change
AST-Based Analysis:
- CRITICAL: Use ast-grep over regular grep/rg for TypeScript code pattern searches
- Think hard about how ast-grep can identify systematic type issues across the codebase
- Use ast-grep for: finding 'any' usage patterns, type assertion patterns, missing null checks, unsafe property access
Analysis Techniques:
- Use union types, intersection types, and conditional types to model complex data structures
- Leverage TypeScript utility types (Partial, Pick, Omit, etc.) for type transformations
- Recommend proper generic constraints and type guards for dynamic typing scenarios
- Identify opportunities for discriminated unions for handling different data shapes
- Suggest mapped types and template literal types for advanced type manipulation
Review Documentation:
- Save comprehensive review findings to ~/code/claude-docs/<git-repo-name>/<branch>/typescript-review-<datetime>.md
- Include specific error analysis with root cause identification
- Provide prioritized todo list with implementation recommendations
- Document type safety considerations and potential risks
- Explain reasoning behind complex type solution recommendations
- Flag areas requiring explicit approval (type assertions, complex workarounds)
Quality Standards for Recommendations:
- All recommended solutions must compile without errors when implemented
- Ensure recommended types accurately reflect runtime behavior
- Identify potential new type errors that solutions might introduce elsewhere
- Consider edge cases and boundary conditions in type recommendations
- Provide clear explanations for complex type solutions with detailed reasoning
Your role is to act as a comprehensive TypeScript code reviewer, providing thorough analysis and actionable guidance that enables the fixer agent to systematically resolve type safety issues while maintaining code functionality and improving overall type safety.

View File

@@ -0,0 +1,116 @@
---
name: vue3-architecture-reviewer
description: Use this agent when you need expert analysis of Vue 3 components, templates, and architectural patterns. This agent specializes in reviewing Vue code for best practices, explaining complex Vue patterns to engineers new to the framework, and providing critical architectural feedback.\n\nExamples:\n- <example>\n Context: The user wants to understand a complex Vue component they've encountered.\n user: "Can you explain what's happening in this UserProfile.vue component?"\n assistant: "I'll use the vue3-architecture-reviewer agent to analyze this component and explain its patterns."\n <commentary>\n Since the user is asking for Vue component analysis and explanation, use the vue3-architecture-reviewer agent.\n </commentary>\n</example>\n- <example>\n Context: The user has written new Vue components and wants architectural review.\n user: "I've just created these three Vue components for our dashboard. Here are the files..."\n assistant: "Let me use the vue3-architecture-reviewer agent to review these components for Vue 3 best practices and architectural patterns."\n <commentary>\n The user has Vue components that need review, so the vue3-architecture-reviewer agent should analyze them.\n </commentary>\n</example>\n- <example>\n Context: The user is refactoring legacy Vue 2 code to Vue 3.\n user: "I'm migrating this component from Vue 2 to Vue 3. Can you check if I'm following Vue 3 patterns correctly?"\n assistant: "I'll use the vue3-architecture-reviewer agent to review your migration and ensure it follows Vue 3 best practices."\n <commentary>\n Migration review requires deep Vue 3 expertise, perfect for the vue3-architecture-reviewer agent.\n </commentary>\n</example>
tools: Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, BashOutput, KillBash
model: sonnet
color: blue
---
You are an elite Vue 3 architecture specialist with deep expertise in modern frontend development patterns, component design, and Vue ecosystem best practices. You have extensive experience architecting large-scale Vue applications and mentoring senior engineers transitioning to Vue from other frameworks.
**Your Core Mission**: Analyze Vue 3 components, templates, and architectural patterns with exceptional critical thinking. You provide clear, educational explanations tailored for staff-level frontend engineers who are new to Vue, while maintaining the highest standards for code quality and architectural excellence.
**Analysis Framework**:
1. **Initial Component Assessment**:
- ULTRATHINK CRITICALLY about every aspect of the code
- Identify the component's purpose and architectural role
- Map out data flow, reactivity patterns, and component boundaries
- Assess template structure and directive usage
- Evaluate composition API usage vs options API (if applicable)
2. **Deep Dive Analysis**:
- **Reactivity System**: Examine ref/reactive usage, computed properties, watchers
- **Component Communication**: Props validation, emit patterns, provide/inject usage
- **Template Patterns**: v-if/v-show decisions, v-for key usage, event handling
- **Lifecycle Management**: Hook usage, cleanup patterns, side effect management
- **Performance Considerations**: Unnecessary re-renders, computed vs methods, lazy loading
- **Type Safety**: TypeScript integration, prop types, emit types
3. **Best Practices Evaluation**:
- Single Responsibility Principle adherence
- Composable extraction opportunities
- Template readability and maintainability
- Proper separation of concerns
- Accessibility considerations
- Error boundary implementation
- Testing considerations
4. **Documentation Resources**:
You should reference these Vue documentation pages as needed:
- Template Syntax: https://vuejs.org/guide/essentials/template-syntax.html
- Reactivity Fundamentals: https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- Computed Properties: https://vuejs.org/guide/essentials/computed.html
- Class and Style Bindings: https://vuejs.org/guide/essentials/class-and-style.html
- Conditional Rendering: https://vuejs.org/guide/essentials/conditional.html
- List Rendering: https://vuejs.org/guide/essentials/list.html
- Event Handling: https://vuejs.org/guide/essentials/event-handling.html
- Form Input Bindings: https://vuejs.org/guide/essentials/forms.html
- Watchers: https://vuejs.org/guide/essentials/watchers.html
- Template Refs: https://vuejs.org/guide/essentials/template-refs.html
- Components Basics: https://vuejs.org/guide/essentials/component-basics.html
- Lifecycle Hooks: https://vuejs.org/guide/essentials/lifecycle.html
When you need additional context, search vuejs.org for specific keywords.
**Output Structure**:
1. **Executive Summary**: Brief overview of the component's purpose and overall health
2. **Architecture Analysis**:
- Component structure and organization
- Data flow and state management patterns
- Integration points and dependencies
3. **Vue 3 Pattern Compliance**:
- ✅ What follows best practices (with explanation)
- ⚠️ Areas for improvement (with specific recommendations)
- ❌ Anti-patterns or violations (with refactoring suggestions)
4. **Educational Insights**:
- Explain Vue-specific concepts in context
- Compare to patterns from React/Angular if helpful
- Provide "why" behind Vue conventions
5. **Code Examples**:
When suggesting improvements, provide concrete before/after examples:
```vue
<!-- Current approach -->
[problematic code]
<!-- Recommended approach -->
[improved code]
<!-- Explanation: Why this is better -->
```
6. **Performance & Scalability**:
- Identify potential performance bottlenecks
- Suggest optimization strategies
- Consider future scaling needs
7. **Action Items**:
- Prioritized list of improvements (Critical/High/Medium/Low)
- Estimated effort for each change
- Learning resources for unfamiliar concepts
**Critical Thinking Guidelines**:
- Question every architectural decision - is there a simpler way?
- Look for hidden complexity that could be abstracted
- Identify missing error handling or edge cases
- Consider the component's reusability and testability
- Evaluate naming conventions and code clarity
- Check for proper TypeScript usage if applicable
- Assess whether reactivity is used appropriately
- Verify that computed properties aren't causing unnecessary recalculations
- Ensure watchers are properly cleaned up
- Look for memory leak potential
**Communication Style**:
- Be direct but educational - explain the "why" behind issues
- Use concrete examples from the actual code
- Provide context from Vue documentation when introducing concepts
- Balance criticism with recognition of good patterns
- Tailor explanations for someone with strong frontend skills but new to Vue
- Use analogies to other frameworks when helpful
Remember: Your goal is not just to review, but to educate and elevate the engineer's Vue 3 expertise through practical, contextual learning. Every piece of feedback should make them a better Vue developer.

View File

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

22
pnpm-lock.yaml generated
View File

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

View File

@@ -1,241 +0,0 @@
#!/bin/bash
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- PLAYWRIGHT_TEST_STATUS -->"
# Use dot notation for artifact names (as Playwright creates them)
BROWSERS="chromium chromium-2x chromium-0.5x mobile-chrome"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
deploy_report() {
dir="$1"
browser="$2"
branch="$3"
[ ! -d "$dir" ] && echo "failed" && return
# Project name with dots converted to dashes for Cloudflare
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch and project are already sanitized, use them directly
# Branch was sanitized at script start, project uses sanitized_browser
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
# Create new comment
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
else
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for reports in: $(pwd)/reports"
echo "Available reports:"
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
# Deploy all reports in parallel and collect URLs
temp_dir=$(mktemp -d)
pids=""
i=0
# Start parallel deployments
for browser in $BROWSERS; do
if [ -d "reports/playwright-report-$browser" ]; then
echo "Found report for $browser, deploying in parallel..."
(
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
echo "$url" > "$temp_dir/$i.url"
echo "Deployment result for $browser: $url"
) &
pids="$pids $!"
else
echo "Report not found for $browser at reports/playwright-report-$browser"
echo "failed" > "$temp_dir/$i.url"
fi
i=$((i + 1))
done
# Wait for all deployments to complete
for pid in $pids; do
wait $pid
done
# Collect URLs in order
urls=""
i=0
for browser in $BROWSERS; do
if [ -f "$temp_dir/$i.url" ]; then
url=$(cat "$temp_dir/$i.url")
else
url="failed"
fi
if [ -z "$urls" ]; then
urls="$url"
else
urls="$urls $url"
fi
i=$((i + 1))
done
# Clean up temp directory
rm -rf "$temp_dir"
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
✅ **Tests completed successfully!**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 📊 Test Reports by Browser"
# Add browser results
i=0
for browser in $BROWSERS; do
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
comment="$comment
- ✅ **${browser}**: [View Report](${url})"
else
comment="$comment
- ❌ **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -7,6 +7,66 @@
@config '../../../tailwind.config.ts';
@layer tailwind-utilities {
/* Set default values to prevent some styles from not working properly. */
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(66 153 225 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
@tailwind components;
@tailwind utilities;
}
:root {
--fg-color: #000;
--bg-color: #fff;
@@ -47,91 +107,6 @@
}
}
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Palette Colors */
--color-charcoal-100: #171718;
--color-charcoal-200: #202121;
--color-charcoal-300: #262729;
--color-charcoal-400: #2d2e32;
--color-charcoal-500: #313235;
--color-charcoal-600: #3c3d42;
--color-charcoal-700: #494a50;
--color-charcoal-800: #55565e;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-gray-100: #f3f3f3;
--color-gray-200: #e9e9e9;
--color-gray-300: #e1e1e1;
--color-gray-400: #d9d9d9;
--color-gray-500: #c5c5c5;
--color-gray-600: #b4b4b4;
--color-gray-700: #a0a0a0;
--color-gray-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-brand-yellow: #f0ff41;
--color-brand-blue: #172dd7;
--color-blue-100: #0b8ce9;
--color-blue-200: #31b9f4;
--color-success-100: #00cd72;
--color-success-200: #47e469;
--color-warning-100: #fd9903;
--color-warning-200: #fcbf64;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-error: #962a2a;
--color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@custom-variant dark-theme {
.dark-theme & {
@slot;
}
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
/* Everthing below here to be cleaned up over time. */
body {
width: 100vw;
height: 100vh;
@@ -874,7 +849,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation {
.comfy-preview-3d-animation{
display: flex;
flex-direction: column;
background: transparent;
@@ -887,7 +862,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas {
.comfy-load-3d-viewer canvas{
display: flex;
width: 100% !important;
height: 100% !important;
@@ -964,9 +939,7 @@ audio.comfy-audio.empty-audio-widget {
.lg-node .lg-slot,
.lg-node .lg-widget {
transition:
opacity 0.1s ease,
font-size 0.1s ease;
transition: opacity 0.1s ease, font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
@@ -998,3 +971,4 @@ audio.comfy-audio.empty-audio-widget {
/* Use solid colors only */
background-image: none !important;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -115,7 +116,6 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'

View File

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

View File

@@ -87,6 +87,8 @@
<template #content>
<!-- Card Examples -->
<!-- <div class="min-h-0 px-6 py-4 overflow-y-auto scrollbar-hide"> -->
<!-- <h2 class="text-xxl py-4 pt-0 m-0">{{ $t('Checkpoints') }}</h2> -->
<div class="flex flex-wrap gap-2">
<CardContainer
v-for="i in 100"
@@ -173,20 +175,20 @@ const sortOptions = ref([
])
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5', icon: 'icon-[lucide--tag]' },
{ id: 'tag-sdxl', label: 'SDXL', icon: 'icon-[lucide--tag]' },
{ id: 'tag-utility', label: 'Utility', icon: 'icon-[lucide--tag]' }
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
{ id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])

View File

@@ -1,4 +1,19 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
@@ -79,7 +94,20 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
},
setup() {
const t = (k: string) => k
@@ -90,44 +118,20 @@ const createStoryTemplate = (args: StoryArgs) => ({
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--folder]'
},
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: 'icon-[lucide--tag]'
}
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{
id: 'cat-models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])
@@ -177,7 +181,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -199,7 +203,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<i class="icon-[lucide--upload] size-3" />
<Upload :size="12" />
</template>
</IconTextButton>
@@ -211,7 +215,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--download] size-3" />
<Download :size="12" />
</template>
</IconTextButton>
@@ -221,7 +225,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--scroll] size-3" />
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
@@ -252,7 +256,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<i class="icon-[lucide--filter] size-3" />
<Filter :size="12" />
</template>
</SingleSelect>
</div>
@@ -273,7 +277,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<i class="icon-[lucide--info] size-4" />
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
@@ -281,7 +285,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
<Folder :size="12" />
</template>
</SquareChip>
</template>
@@ -301,7 +305,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<Puzzle :size="16" class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -323,7 +327,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<i class="icon-[lucide--upload] size-3" />
<Upload :size="12" />
</template>
</IconTextButton>
@@ -335,7 +339,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--download] size-3" />
<Download :size="12" />
</template>
</IconTextButton>
@@ -345,7 +349,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--scroll] size-3" />
<Scroll :size="12" />
</template>
</IconTextButton>
</template>
@@ -373,7 +377,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<i class="icon-[lucide--filter] size-3" />
<Filter :size="12" />
</template>
</SingleSelect>
</div>
@@ -394,7 +398,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<i class="icon-[lucide--info] size-4" />
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
@@ -402,7 +406,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
<Folder :size="12" />
</template>
</SquareChip>
</template>

View File

@@ -1,11 +0,0 @@
<template>
<i :class="icon" class="text-xs text-neutral" />
</template>
<script setup lang="ts">
import { NavItemData } from '@/types/navTypes'
defineProps<{
icon: NavItemData['icon']
}>()
</script>

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NavItem from './NavItem.vue'
const meta: Meta<typeof NavItem> = {
title: 'Components/Widget/Nav/NavItem',
component: NavItem,
argTypes: {
icon: {
control: 'select',
description: 'Icon component to display'
},
active: {
control: 'boolean',
description: 'Active state of the nav item'
},
onClick: {
table: { disable: true }
},
default: {
control: 'text',
description: 'Text content for the nav item'
}
},
args: {
active: false,
onClick: () => {},
default: 'Navigation Item'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const InteractiveList: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-1">
<NavItem
v-for="item in items"
:key="item.id"
:icon="item.icon"
:active="selectedId === item.id"
:on-click="() => selectedId = item.id"
>
{{ item.label }}
</NavItem>
</div>
`,
data() {
return {
selectedId: 'downloads'
}
},
setup() {
const items = [
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
},
{
id: 'tags',
label: 'Tags',
icon: 'icon-[lucide--tag]'
},
{
id: 'settings',
label: 'Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'default',
label: 'Default Icon',
icon: 'icon-[lucide--folder]'
}
]
return { items }
}
}),
parameters: {
controls: { disable: true }
}
}

View File

@@ -9,8 +9,7 @@
role="button"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<i-lucide:folder v-else class="text-xs text-neutral" />
<i-lucide:folder v-if="hasFolderIcon" class="text-xs text-neutral" />
<span class="flex items-center">
<slot></slot>
</span>
@@ -18,12 +17,12 @@
</template>
<script setup lang="ts">
import { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
const { icon, active, onClick } = defineProps<{
icon: NavItemData['icon']
const {
hasFolderIcon = true,
active,
onClick
} = defineProps<{
hasFolderIcon?: boolean
active?: boolean
onClick: () => void
}>()

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
BarChart3,
Bell,
BookOpen,
FolderOpen,
GraduationCap,
Home,
LogOut,
MessageSquare,
Settings,
User,
Users
} from 'lucide-vue-next'
import { ref } from 'vue'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import NavItem from './NavItem.vue'
import NavTitle from './NavTitle.vue'
const meta: Meta = {
title: 'Components/Widget/Navigation',
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const NavigationItem: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-2">
<NavItem>Dashboard</NavItem>
<NavItem>Projects</NavItem>
<NavItem>Messages</NavItem>
<NavItem>Settings</NavItem>
</div>
`
})
}
export const CustomNavigation: Story = {
render: () => ({
components: {
NavTitle,
NavItem,
Home,
FolderOpen,
BarChart3,
Users,
BookOpen,
GraduationCap,
MessageSquare,
Settings,
User,
Bell,
LogOut
},
template: `
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
<NavTitle title="Main Menu" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
</div>
<div class="mt-6">
<NavTitle title="Resources" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
</div>
</div>
<div class="mt-6">
<NavTitle title="Account" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
</div>
</div>
</nav>
`
})
}
export const LeftSidePanelDemo: Story = {
render: () => ({
components: { LeftSidePanel, FolderOpen },
setup() {
const navItems = [
{
title: 'Workspace',
items: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'projects', label: 'Projects' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'models', label: 'Models' }
]
},
{
title: 'Tools',
items: [
{ id: 'node-editor', label: 'Node Editor' },
{ id: 'image-browser', label: 'Image Browser' },
{ id: 'queue-manager', label: 'Queue Manager' },
{ id: 'extensions', label: 'Extensions' }
]
},
{ id: 'settings', label: 'Settings' }
]
const active = ref<string | null>(null)
return { navItems, active }
},
template: `
<div class="w-full h-[560px] flex">
<div class="w-64 rounded-lg">
<LeftSidePanel v-model="active" :nav-items="navItems">
<template #header-icon>
<FolderOpen :size="14" />
</template>
<template #header-title>
Navigation
</template>
</LeftSidePanel>
</div>
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
Active: {{ active ?? 'None' }}
</div>
</div>
`
})
}

View File

@@ -1,242 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import LeftSidePanel from './LeftSidePanel.vue'
const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: 'installed',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const WithGroups: Story = {
args: {
modelValue: 'tag-sd15',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
title: 'TAGS',
items: [
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: 'icon-[lucide--tag]'
}
]
},
{
title: 'CATEGORIES',
items: [
{
id: 'cat-models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
</div>
`
})
}
export const DefaultIcons: Story = {
args: {
modelValue: 'home',
navItems: [
{
id: 'home',
label: 'Home',
icon: 'icon-[lucide--folder]'
},
{
id: 'documents',
label: 'Documents',
icon: 'icon-[lucide--folder]'
},
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--folder]'
},
{
id: 'desktop',
label: 'Desktop',
icon: 'icon-[lucide--folder]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const LongLabels: Story = {
args: {
modelValue: 'general',
navItems: [
{
id: 'general',
label: 'General Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'appearance',
label: 'Appearance & Themes Configuration',
icon: 'icon-[lucide--wrench]'
},
{
title: 'ADVANCED OPTIONS',
items: [
{
id: 'performance',
label: 'Performance & Optimization Settings',
icon: 'icon-[lucide--zap]'
},
{
id: 'experimental',
label: 'Experimental Features (Beta)',
icon: 'icon-[lucide--puzzle]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
</div>
`
})
}

View File

@@ -14,7 +14,6 @@
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
@@ -23,7 +22,6 @@
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:active="activeItem === item.id"
@click="activeItem = item.id"
>

View File

@@ -11,7 +11,8 @@
import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager {
@@ -20,7 +21,7 @@ interface NodeManager {
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const layoutMutations = useLayoutMutations()
/**
* Handle node selection events
@@ -50,7 +51,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id)
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
}
// Update canvas selection tracking
@@ -169,13 +171,14 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAll()
canvasStore.canvas.deselectAllNodes()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.select(node)
canvasStore.canvas.selectNode(node)
node.selected = true
}
})

View File

@@ -1511,32 +1511,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return 'Token-based'
}
},
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
const maxImagesWidget = node.widgets?.find(
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
}
const maxImages = Number(maxImagesWidget.value)
if (maxImages === 1) {
return '$0.03/Run'
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
}
},
ByteDanceTextToVideoNode: {
displayPrice: byteDanceVideoPricingCalculator
},
@@ -1639,11 +1613,6 @@ export const useNodePricing = () => {
// ByteDance
ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model'],
ByteDanceSeedreamNode: [
'model',
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],

View File

@@ -36,14 +36,7 @@ interface CivitaiModelVersionResponse {
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
// Common optional Civitai API fields
createdAt?: string
publishedAt?: string
trainedWords?: string[]
stats?: Record<string, number>
description?: string
// Allow additional API evolution
[key: string]: string | number | boolean | object | undefined
[key: string]: any
}
/**

View File

@@ -220,9 +220,9 @@ export function useConflictDetection() {
abortController.value?.signal
)
if (bulkResponse?.node_versions) {
if (bulkResponse && bulkResponse.node_versions) {
// Process bulk response
bulkResponse?.node_versions?.forEach((result) => {
bulkResponse.node_versions.forEach((result) => {
if (result.status === 'success' && result.node_version) {
versionDataMap.set(
result.identifier.node_id,
@@ -265,7 +265,7 @@ export function useConflictDetection() {
const requirement: NodePackRequirements = {
// Basic package info
id: packageId,
name: packInfo?.name ?? packageId,
name: packInfo?.name || packageId,
installed_version: installedVersion,
is_enabled: isEnabled,
@@ -291,7 +291,7 @@ export function useConflictDetection() {
// Create fallback requirement without Registry data
const fallbackRequirement: NodePackRequirements = {
id: packageId,
name: packInfo?.name ?? packageId,
name: packInfo?.name || packageId,
installed_version: installedVersion,
is_enabled: isEnabled,
is_banned: false,
@@ -326,9 +326,7 @@ export function useConflictDetection() {
const conflicts: ConflictDetail[] = []
// Helper function to check if a value indicates "compatible with all"
const isCompatibleWithAll = (
value: string | string[] | null | undefined
): boolean => {
const isCompatibleWithAll = (value: any): boolean => {
if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true

View File

@@ -8,11 +8,6 @@ import type {
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { normalizeI18nKey } from '@/utils/formatUtil'
interface ContextMenuExtraInfo {
inputs?: INodeInputSlot[]
widgets?: IWidget[]
}
/**
* Add translation for litegraph context menu.
*/
@@ -55,20 +50,18 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo: ContextMenuExtraInfo | undefined =
(options.extra as ContextMenuExtraInfo) ||
(options.parentMenu?.options?.extra as ContextMenuExtraInfo)
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
// widgets and inputs
const matchInput = value.content?.match(reInput)
if (matchInput) {
let match = matchInput[1]
extraInfo?.inputs?.find((i: INodeInputSlot) => {
if (i.name != match) return false
match = i.label ?? i.name
match = i.label ? i.label : i.name
})
extraInfo?.widgets?.find((i: IWidget) => {
if (i.name != match) return false
match = i.label ?? i.name
match = i.label ? i.label : i.name
})
value.content = cvt + match + tinp
continue
@@ -78,11 +71,11 @@ export const useContextMenuTranslation = () => {
let match = matchWidget[1]
extraInfo?.inputs?.find((i: INodeInputSlot) => {
if (i.name != match) return false
match = i.label ?? i.name
match = i.label ? i.label : i.name
})
extraInfo?.widgets?.find((i: IWidget) => {
if (i.name != match) return false
match = i.label ?? i.name
match = i.label ? i.label : i.name
})
value.content = cvt + match + twgt
continue

View File

@@ -30,7 +30,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -106,7 +106,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'Save',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
await workflowService.saveWorkflow(workflow)
@@ -128,7 +128,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'Save As',
category: 'essentials' as const,
function: async () => {
const workflow = useWorkflowStore().activeWorkflow
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
await workflowService.saveWorkflowAs(workflow)

View File

@@ -41,7 +41,7 @@ export function useDownload(url: string, fileName?: string) {
link.href = downloadUrlToHfRepoUrl(url)
} else {
link.href = url
link.download = fileName ?? url.split('/').pop() ?? 'download'
link.download = fileName || url.split('/').pop() || 'download'
}
link.target = '_blank' // Opens in new tab if download attribute is not supported
link.rel = 'noopener noreferrer' // Security best practice for _blank links

View File

@@ -13,7 +13,7 @@ export function useErrorHandling() {
}
const wrapWithErrorHandling =
<TArgs extends readonly unknown[], TReturn>(
<TArgs extends any[], TReturn>(
action: (...args: TArgs) => TReturn,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
@@ -29,7 +29,7 @@ export function useErrorHandling() {
}
const wrapWithErrorHandlingAsync =
<TArgs extends readonly unknown[], TReturn>(
<TArgs extends any[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: any) => void,
finallyHandler?: () => void

View File

@@ -3,7 +3,6 @@ import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
CameraState,
CameraType,
MaterialMode,
UpDirection
@@ -19,7 +18,7 @@ interface Load3dViewerState {
cameraType: CameraType
fov: number
lightIntensity: number
cameraState: CameraState | null
cameraState: any
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
@@ -275,9 +274,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
nodeValue.properties['FOV'] = initialState.value.fov
nodeValue.properties['Light Intensity'] =
initialState.value.lightIntensity
if (initialState.value.cameraState) {
nodeValue.properties['Camera Info'] = initialState.value.cameraState
}
nodeValue.properties['Camera Info'] = initialState.value.cameraState
nodeValue.properties['Background Image'] =
initialState.value.backgroundImage
}

View File

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

View File

@@ -7,29 +7,28 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-charcoal-100',
'bg-white dark-theme:bg-[#15161A]',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline-transparent outline-2',
'outline outline-transparent outline-2',
{
'outline-black dark-theme:outline-white': isSelected
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-sand-100 dark-theme:border-charcoal-300': !isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
'will-change-transform': isDragging
},
lodCssClass,
'pointer-events-auto'
lodCssClass
)
"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex
pointerEvents: 'auto'
},
dragStyle
]"
@@ -193,7 +192,6 @@ onErrorCaptured((error) => {
// Use layout system for node position and dragging
const {
position: layoutPosition,
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
@@ -226,8 +224,7 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses =
'bg-sand-primary dark-theme:bg-charcoal-tertiary h-[1px] mx-0'
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(

View File

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

View File

@@ -1,36 +0,0 @@
/**
* Node Z-Index Management Composable
*
* Provides focused functionality for managing node layering through z-index.
* Integrates with the layout system to ensure proper visual ordering.
*/
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
interface NodeZIndexOptions {
/**
* Layout source for z-index mutations
* @default LayoutSource.Vue
*/
layoutSource?: LayoutSource
}
export function useNodeZIndex(options: NodeZIndexOptions = {}) {
const { layoutSource = LayoutSource.Vue } = options
const layoutMutations = useLayoutMutations()
/**
* Bring node to front (highest z-index)
* @param nodeId - The node to bring to front
* @param source - Optional source override
*/
function bringNodeToFront(nodeId: NodeId, source?: LayoutSource) {
layoutMutations.setSource(source ?? layoutSource)
layoutMutations.bringNodeToFront(nodeId)
}
return {
bringNodeToFront
}
}

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"
:readonly="readonly"
v-bind="$attrs"
/>
</template>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const modelValue = defineModel<number>({ default: 0 })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
</script>
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="modelValue"
v-bind="filteredProps"
show-buttons
button-layout="horizontal"
size="small"
:disabled="readonly"
:step="stepValue"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',
decrementButton:
'!rounded-l-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40'
}"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
</WidgetLayoutField>
</template>
<style scoped>
:deep(.p-inputnumber-input) {
background-color: transparent;
border: 1px solid color-mix(in oklab, #d4d4d8 10%, transparent);
border-top: transparent;
border-bottom: transparent;
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -18,7 +18,7 @@
:disabled="readonly"
class="w-full text-xs"
size="small"
:rows="6"
rows="6"
:pt="{
root: {
onBlur: handleBlur

View File

@@ -7,9 +7,9 @@ import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetSlider from './WidgetSlider.vue'
describe('WidgetInputNumberSlider Value Binding', () => {
describe('WidgetSlider Value Binding', () => {
const createMockWidget = (
value: number = 5,
options: Partial<SliderProps & { precision?: number }> = {},
@@ -27,7 +27,7 @@ describe('WidgetInputNumberSlider Value Binding', () => {
modelValue: number,
readonly = false
) => {
return mount(WidgetInputNumberSlider, {
return mount(WidgetSlider, {
global: {
plugins: [PrimeVue],
components: { InputText, Slider }

View File

@@ -16,6 +16,8 @@
v-model="inputDisplayValue"
:disabled="readonly"
type="number"
:min="widget.options?.min"
:max="widget.options?.max"
:step="stepValue"
class="w-[4em] text-center text-xs px-0 !border-none !shadow-none !bg-transparent"
size="small"

View File

@@ -9,12 +9,12 @@ import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputNumber from '../components/WidgetInputNumber.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetSelect from '../components/WidgetSelect.vue'
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
import WidgetSlider from '../components/WidgetSlider.vue'
import WidgetTextarea from '../components/WidgetTextarea.vue'
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
@@ -38,11 +38,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
['int', { component: WidgetInputNumber, aliases: ['INT'], essential: true }],
['int', { component: WidgetSlider, aliases: ['INT'], essential: true }],
[
'float',
{
component: WidgetInputNumber,
component: WidgetSlider,
aliases: ['FLOAT', 'number', 'slider'],
essential: true
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
export interface NavItemData {
id: string
label: string
icon: string
}
export interface NavGroupData {

View File

@@ -1,4 +1,3 @@
import lucide from '@iconify-json/lucide/icons.json'
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './build/customIconCollection'
@@ -6,13 +5,257 @@ import { iconCollection } from './build/customIconCollection'
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
fontSize: {
xxs: '0.625rem',
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem'
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
'3xl': '1800px',
'4xl': '2500px',
'5xl': '3200px'
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.125rem',
1: '0.25rem',
1.5: '0.375rem',
2: '0.5rem',
2.5: '0.625rem',
3: '0.75rem',
3.5: '0.875rem',
4: '1rem',
4.5: '1.125rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
11: '2.75rem',
12: '3rem',
14: '3.5rem',
16: '4rem',
18: '4.5rem',
20: '5rem',
24: '6rem',
28: '7rem',
32: '8rem',
36: '9rem',
40: '10rem',
44: '11rem',
48: '12rem',
52: '13rem',
56: '14rem',
60: '15rem',
64: '16rem',
72: '18rem',
75: '18.75rem',
80: '20rem',
84: '22rem',
90: '24rem',
96: '26rem',
100: '28rem',
110: '32rem'
},
extend: {
colors: {
zinc: {
50: '#fafafa',
100: '#8282821a',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#A1A3AE',
500: '#71717a',
600: '#52525b',
700: '#38393b',
800: '#262729',
900: '#18181b',
950: '#09090b'
},
gray: {
50: '#f8fbfc',
100: '#f3f6fa',
200: '#edf2f7',
300: '#e2e8f0',
400: '#cbd5e0',
500: '#a0aec0',
600: '#718096',
700: '#4a5568',
800: '#2d3748',
900: '#1a202c',
950: '#0a1016'
},
teal: {
50: '#f0fdfa',
100: '#e0fcff',
200: '#bef8fd',
300: '#87eaf2',
400: '#54d1db',
500: '#38bec9',
600: '#2cb1bc',
700: '#14919b',
800: '#0e7c86',
900: '#005860',
950: '#022c28'
},
blue: {
50: '#eff6ff',
100: '#ebf8ff',
200: '#bee3f8',
300: '#90cdf4',
400: '#63b3ed',
500: '#4299e1',
600: '#3182ce',
700: '#2b6cb0',
800: '#2c5282',
900: '#2a4365',
950: '#172554'
},
green: {
50: '#fcfff5',
100: '#fafff3',
200: '#eaf9c9',
300: '#d1efa0',
400: '#b2e16e',
500: '#96ce4c',
600: '#7bb53d',
700: '#649934',
800: '#507b2e',
900: '#456829',
950: '#355819'
},
fuchsia: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e'
},
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fedbb8',
300: '#fbd38d',
400: '#f6ad55',
500: '#ed8936',
600: '#dd6b20',
700: '#c05621',
800: '#9c4221',
900: '#7b341e',
950: '#431407'
},
yellow: {
50: '#fffef5',
100: '#fffce8',
200: '#fff8c5',
300: '#fff197',
400: '#ffcc00',
500: '#ffc000',
600: '#e6a800',
700: '#cc9600',
800: '#b38400',
900: '#997200',
950: '#664d00'
}
},
textColor: {
muted: 'var(--p-text-muted-color)',
highlight: 'var(--p-primary-color)'
},
/**
* Box shadows for different elevation levels
* https://m3.material.io/styles/elevation/overview
*/
boxShadow: {
'elevation-0': 'none',
'elevation-1':
'0 0 2px 0px rgb(0 0 0 / 0.01), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-1.5':
'0 0 2px 0px rgb(0 0 0 / 0.025), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-2':
'0 0 10px 0px rgb(0 0 0 / 0.06), 0 6px 8px -2px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
'elevation-3':
'0 0 15px 0px rgb(0 0 0 / 0.10), 0 8px 12px -3px rgb(0 0 0 / 0.09), 0 3px 5px -4px rgb(0 0 0 / 0.06)',
'elevation-4':
'0 0 18px 0px rgb(0 0 0 / 0.12), 0 10px 15px -3px rgb(0 0 0 / 0.11), 0 4px 6px -4px rgb(0 0 0 / 0.08)',
'elevation-5':
'0 0 20px 0px rgb(0 0 0 / 0.14), 0 12px 16px -4px rgb(0 0 0 / 0.13), 0 5px 7px -5px rgb(0 0 0 / 0.10)'
},
/**
* Background colors for different elevation levels
* https://m3.material.io/styles/elevation/overview
*/
backgroundColor: {
'dark-elevation-0': 'rgba(255, 255, 255, 0)',
'dark-elevation-1': 'rgba(255, 255, 255, 0.01)',
'dark-elevation-1.5': 'rgba(255, 255, 255, 0.015)',
'dark-elevation-2': 'rgba(255, 255, 255, 0.03)',
'dark-elevation-3': 'rgba(255, 255, 255, 0.04)',
'dark-elevation-4': 'rgba(255, 255, 255, 0.08)',
'dark-elevation-5': 'rgba(2 55, 255, 255, 0.12)'
}
}
},
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection,
lucide
},
prefix: 'icon'
})
comfy: iconCollection
}
}),
function ({ addVariant }) {
addVariant('dark-theme', '.dark-theme &')
},
function ({ addUtilities }) {
const newUtilities = {
'.scrollbar-hide': {
/* Firefox */
'scrollbar-width': 'none',
/* Webkit-based browsers */
'&::-webkit-scrollbar': {
width: '1px'
},
'&::-webkit-scrollbar-thumb': {
'background-color': 'transparent'
}
}
}
addUtilities(newUtilities)
}
]
}

View File

@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
VueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useCanvasStore } from '@/stores/graphStore'
vi.mock('@/stores/graphStore', () => ({

View File

@@ -1781,38 +1781,6 @@ describe('useNodePricing', () => {
})
})
describe('dynamic pricing - ByteDanceSeedreamNode', () => {
it('should return fallback when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run ($0.03 for one output image)')
})
it('should return $0.03/Run when sequential generation is disabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'disabled' },
{ name: 'max_images', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
})
it('should multiply by max_images when sequential generation is enabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'enabled' },
{ name: 'max_images', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run ($0.03 for one output image)')
})
})
describe('dynamic pricing - ByteDance Seedance video nodes', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations')
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
describe('useNodeZIndex', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('bringNodeToFront', () => {
it('should bring node to front with default source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node1')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Vue)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node1')
})
it('should bring node to front with custom source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node2', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node2')
})
it('should use custom layout source from options', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node3')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.External)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node3')
})
it('should override layout source with explicit source parameter', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node4', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node4')
})
})
})

View File

@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
import WidgetInputNumber from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import WidgetMarkdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetSlider from '@/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue'
import WidgetTextarea from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import {
@@ -20,15 +20,15 @@ describe('widgetRegistry', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetInputNumber)
expect(getComponent('INT')).toBe(WidgetInputNumber)
expect(getComponent('int')).toBe(WidgetSlider)
expect(getComponent('INT')).toBe(WidgetSlider)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
expect(getComponent('number')).toBe(WidgetInputNumber)
expect(getComponent('slider')).toBe(WidgetInputNumber)
expect(getComponent('float')).toBe(WidgetSlider)
expect(getComponent('FLOAT')).toBe(WidgetSlider)
expect(getComponent('number')).toBe(WidgetSlider)
expect(getComponent('slider')).toBe(WidgetSlider)
})
})

View File

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

View File

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

View File

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

View File

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