diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8decf07cd..06139b08a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -7,3 +7,21 @@ c53f197de2a3e0fa66b16dedc65c131235c1c4b6 # Reorganize renderer components into domain-driven folder structure c8a83a9caede7bdb5f8598c5492b07d08c339d49 + +# Domain-driven design (DDD) refactors - September 2025 +# These commits reorganized the codebase into domain-driven architecture + +# [refactor] Improve renderer domain organization (#5552) +6349ceee6c0a57fc7992e85635def9b6e22eaeb2 + +# [refactor] Improve settings domain organization (#5550) +4c8c4a1ad4f53354f700a33ea1b95262aeda2719 + +# [refactor] Improve workflow domain organization (#5584) +ca312fd1eab540cc4ddc0e3d244d38b3858574f0 + +# [refactor] Move thumbnail functionality to renderer/core domain (#5586) +e3bb29ceb8174b8bbca9e48ec7d42cd540f40efa + +# [refactor] Improve updates/notifications domain organization (#5590) +27ab355f9c73415dc39f4d3f512b02308f847801 diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 3f4b93242..907695e57 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -133,11 +133,10 @@ jobs: if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success env: GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} run: | - PR_TITLE="${{ github.event.pull_request.title }}" - PR_NUMBER="${{ github.event.pull_request.number }}" - PR_AUTHOR="${{ github.event.pull_request.user.login }}" - for backport in ${{ steps.backport.outputs.success }}; do IFS=':' read -r version branch <<< "${backport}" diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml index 3ec61cb3c..d1eecafe3 100644 --- a/.github/workflows/claude-pr-review.yml +++ b/.github/workflows/claude-pr-review.yml @@ -47,6 +47,7 @@ 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 @@ -69,19 +70,17 @@ jobs: pnpm install -g typescript @vue/compiler-sfc - name: Run Claude PR Review - uses: anthropics/claude-code-action@main + uses: anthropics/claude-code-action@v1.0.6 with: label_trigger: "claude-review" - direct_prompt: | + 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 }} - 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" + 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'" env: PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-playwright-deploy.yaml b/.github/workflows/pr-playwright-deploy.yaml index 12051fa99..19bb28253 100644 --- a/.github/workflows/pr-playwright-deploy.yaml +++ b/.github/workflows/pr-playwright-deploy.yaml @@ -1,4 +1,4 @@ -name: PR Playwright Deploy and Comment +name: PR Playwright Deploy (Forks) on: workflow_run: @@ -9,272 +9,84 @@ env: DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p' jobs: - deploy-reports: + deploy-and-comment-forked-pr: runs-on: ubuntu-latest - if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' + 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 permissions: + pull-requests: write actions: read - strategy: - fail-fast: false - matrix: - browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome] steps: - - name: Get PR info - id: pr-info + - 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 uses: actions/github-script@v7 with: script: | - const { data: pullRequests } = await github.rest.pulls.list({ + const { data: prs } = 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 { number: null, sanitized_branch: null }; + 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; } - 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 - }; + console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`); + return pr.number; - - name: Set project name - if: fromJSON(steps.pr-info.outputs.result).number != null - id: project-name + - name: Handle Test Start + if: steps.pr.outputs.result != 'null' && github.event.action == 'requested' + env: + GITHUB_TOKEN: ${{ github.token }} run: | - 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 + 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 }}')" - - name: Download playwright report - if: fromJSON(steps.pr-info.outputs.result).number != null + - name: Download and Deploy Reports + if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' uses: actions/download-artifact@v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - 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 + pattern: playwright-report-* + path: reports - 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 + - name: Handle Test Completion + if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - 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 + GITHUB_TOKEN: ${{ github.token }} run: | - echo "" > comment.md - echo "## ๐ŸŽญ Playwright Test Results" >> comment.md - echo "" >> comment.md - echo "comfy-loading-gif **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: '' - 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 "" > 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: '' - comment-author: 'github-actions[bot]' - edit-mode: replace - body-path: comment.md \ No newline at end of file + # 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" \ No newline at end of file diff --git a/.github/workflows/publish-frontend-types.yaml b/.github/workflows/publish-frontend-types.yaml new file mode 100644 index 000000000..398f5e0a7 --- /dev/null +++ b/.github/workflows/publish-frontend-types.yaml @@ -0,0 +1,137 @@ +name: Publish Frontend Types + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 1.26.7)' + required: true + type: string + dist_tag: + description: 'npm dist-tag to use' + required: true + default: latest + type: string + ref: + description: 'Git ref to checkout (commit SHA, tag, or branch)' + required: false + type: string + workflow_call: + inputs: + version: + required: true + type: string + dist_tag: + required: false + type: string + default: latest + ref: + required: false + type: string + +concurrency: + group: publish-frontend-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }} + cancel-in-progress: false + +jobs: + publish_types_manual: + name: Publish @comfyorg/comfyui-frontend-types + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate inputs + shell: bash + run: | + set -euo pipefail + VERSION="${{ inputs.version }}" + SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2 + exit 1 + fi + + - name: Determine ref to checkout + id: resolve_ref + shell: bash + run: | + set -euo pipefail + REF="${{ inputs.ref }}" + VERSION="${{ inputs.version }}" + if [ -n "$REF" ]; then + if ! git check-ref-format --allow-onelevel "$REF"; then + echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2 + exit 1 + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + else + echo "ref=refs/tags/v$VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ steps.resolve_ref.outputs.ref }} + fetch-depth: 1 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 'lts/*' + cache: 'pnpm' + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build types + run: pnpm build:types + + - name: Verify version matches input + id: verify + shell: bash + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + TYPES_PKG_VERSION=$(node -p "require('./dist/package.json').version") + if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "Error: package.json version $PKG_VERSION does not match input ${{ inputs.version }}" >&2 + exit 1 + fi + if [ "$TYPES_PKG_VERSION" != "${{ inputs.version }}" ]; then + echo "Error: dist/package.json version $TYPES_PKG_VERSION does not match input ${{ inputs.version }}" >&2 + exit 1 + fi + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + + - name: Check if version already on npm + id: check_npm + shell: bash + run: | + set -euo pipefail + NAME=$(node -p "require('./dist/package.json').name") + VER="${{ steps.verify.outputs.version }}" + STATUS=0 + OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish." + else + if echo "$OUTPUT" | grep -q "E404"; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error title=Registry lookup failed::$OUTPUT" >&2 + exit "$STATUS" + fi + fi + + - name: Publish package + if: steps.check_npm.outputs.exists == 'false' + run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" + working-directory: dist + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8958ce147..c359e3da4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -18,7 +18,7 @@ jobs: is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download dist artifact uses: actions/download-artifact@v4 with: @@ -98,7 +98,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download dist artifact uses: actions/download-artifact@v4 with: @@ -126,34 +126,8 @@ jobs: publish_types: needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - cache: 'pnpm' - registry-url: https://registry.npmjs.org - - - name: Cache tool outputs - uses: actions/cache@v4 - with: - path: | - .cache - tsconfig.tsbuildinfo - dist - key: types-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - types-tools-cache-${{ runner.os }}- - - - run: pnpm install --frozen-lockfile - - run: pnpm build:types - - name: Publish package - run: pnpm publish --access public - working-directory: dist - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + uses: ./.github/workflows/publish-frontend-types.yaml + with: + version: ${{ needs.build.outputs.version }} + ref: ${{ github.event.pull_request.merge_commit_sha }} + secrets: inherit diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index 451c4b903..eaaaefee0 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -229,7 +229,13 @@ jobs: - name: Run Playwright tests (${{ matrix.browser }}) id: playwright - run: npx playwright test --project=${{ matrix.browser }} --reporter=html + run: | + # Run tests with both HTML and JSON reporters + PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ + npx playwright test --project=${{ matrix.browser }} \ + --reporter=list \ + --reporter=html \ + --reporter=json working-directory: ComfyUI_frontend - uses: actions/upload-artifact@v4 @@ -275,7 +281,12 @@ jobs: merge-multiple: true - name: Merge into HTML Report - run: npx playwright merge-reports --reporter html ./all-blob-reports + run: | + # Generate HTML report + npx playwright merge-reports --reporter=html ./all-blob-reports + # Generate JSON report separately with explicit output path + PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ + npx playwright merge-reports --reporter=json ./all-blob-reports working-directory: ComfyUI_frontend - name: Upload HTML report @@ -284,3 +295,65 @@ 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) \ No newline at end of file diff --git a/.github/workflows/update-electron-types.yaml b/.github/workflows/update-electron-types.yaml index 0dfcdea34..96f85f6b0 100644 --- a/.github/workflows/update-electron-types.yaml +++ b/.github/workflows/update-electron-types.yaml @@ -40,7 +40,7 @@ jobs: - name: Get new version id: get-version run: | - NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version') + NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version') echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Create Pull Request diff --git a/.gitignore b/.gitignore index db0b8454c..5a58d1b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ tests-ui/workflows/examples /blob-report/ /playwright/.cache/ browser_tests/**/*-win32.png +browser_tests/local/ .env diff --git a/.husky/pre-commit b/.husky/pre-commit index 6b8a399e4..578271509 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env bash -npx lint-staged -npx tsx scripts/check-unused-i18n-keys.ts \ No newline at end of file +pnpm exec lint-staged +pnpm exec tsx scripts/check-unused-i18n-keys.ts diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..ec1fc17d0 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +# Run Knip with cache via package script +pnpm knip + diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ae90f7051 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-workspace-root-check=true diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 76aca2401..05e082ef0 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -57,9 +57,8 @@ /* Override Storybook's problematic & selector styles */ /* Reset only the specific properties that Storybook injects */ - #storybook-root li+li, - #storybook-docs li+li { - margin: inherit; - padding: inherit; + li+li { + margin: 0; + padding: revert-layer; } \ No newline at end of file diff --git a/browser_tests/fixtures/ComfyMouse.ts b/browser_tests/fixtures/ComfyMouse.ts index 306f4352b..dfb0281eb 100644 --- a/browser_tests/fixtures/ComfyMouse.ts +++ b/browser_tests/fixtures/ComfyMouse.ts @@ -10,7 +10,7 @@ import type { Position } from './types' * - {@link Mouse.move} * - {@link Mouse.up} */ -export interface DragOptions { +interface DragOptions { button?: 'left' | 'right' | 'middle' clickCount?: number steps?: number diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index c32dd3937..c9a8820f5 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -5,13 +5,14 @@ import dotenv from 'dotenv' import * as fs from 'fs' import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph' -import type { NodeId } from '../../src/schemas/comfyWorkflowSchema' +import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema' import type { KeyCombo } from '../../src/schemas/keyBindingSchema' import type { useWorkspaceStore } from '../../src/stores/workspaceStore' import { NodeBadgeMode } from '../../src/types/nodeSource' import { ComfyActionbar } from '../helpers/actionbar' import { ComfyTemplates } from '../helpers/templates' import { ComfyMouse } from './ComfyMouse' +import { VueNodeHelpers } from './VueNodeHelpers' import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' import { SettingDialog } from './components/SettingDialog' import { @@ -144,6 +145,7 @@ export class ComfyPage { public readonly templates: ComfyTemplates public readonly settingDialog: SettingDialog public readonly confirmDialog: ConfirmDialog + public readonly vueNodes: VueNodeHelpers /** Worker index to test user ID */ public readonly userIds: string[] = [] @@ -172,6 +174,7 @@ export class ComfyPage { this.templates = new ComfyTemplates(page) this.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) + this.vueNodes = new VueNodeHelpers(page) } convertLeafToContent(structure: FolderStructure): FolderStructure { @@ -1421,7 +1424,7 @@ export class ComfyPage { } async closeDialog() { - await this.page.locator('.p-dialog-close-button').click() + await this.page.locator('.p-dialog-close-button').click({ force: true }) await expect(this.page.locator('.p-dialog')).toBeHidden() } diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts new file mode 100644 index 000000000..e3b3de542 --- /dev/null +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -0,0 +1,110 @@ +/** + * Vue Node Test Helpers + */ +import type { Locator, Page } from '@playwright/test' + +export class VueNodeHelpers { + constructor(private page: Page) {} + + /** + * Get locator for all Vue node components in the DOM + */ + get nodes(): Locator { + return this.page.locator('[data-node-id]') + } + + /** + * Get locator for selected Vue node components (using visual selection indicators) + */ + get selectedNodes(): Locator { + return this.page.locator( + '[data-node-id].outline-black, [data-node-id].outline-white' + ) + } + + /** + * Get total count of Vue nodes in the DOM + */ + async getNodeCount(): Promise { + return await this.nodes.count() + } + + /** + * Get count of selected Vue nodes + */ + async getSelectedNodeCount(): Promise { + return await this.selectedNodes.count() + } + + /** + * Get all Vue node IDs currently in the DOM + */ + async getNodeIds(): Promise { + return await this.nodes.evaluateAll((nodes) => + nodes + .map((n) => n.getAttribute('data-node-id')) + .filter((id): id is string => id !== null) + ) + } + + /** + * Select a specific Vue node by ID + */ + async selectNode(nodeId: string): Promise { + await this.page.locator(`[data-node-id="${nodeId}"]`).click() + } + + /** + * Select multiple Vue nodes by IDs using Ctrl+click + */ + async selectNodes(nodeIds: string[]): Promise { + if (nodeIds.length === 0) return + + // Select first node normally + await this.selectNode(nodeIds[0]) + + // Add additional nodes with Ctrl+click + for (let i = 1; i < nodeIds.length; i++) { + await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({ + modifiers: ['Control'] + }) + } + } + + /** + * Clear all selections by clicking empty space + */ + async clearSelection(): Promise { + await this.page.mouse.click(50, 50) + } + + /** + * Delete selected Vue nodes using Delete key + */ + async deleteSelected(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Delete') + } + + /** + * Delete selected Vue nodes using Backspace key + */ + async deleteSelectedWithBackspace(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Backspace') + } + + /** + * Wait for Vue nodes to be rendered + */ + async waitForNodes(expectedCount?: number): Promise { + if (expectedCount !== undefined) { + await this.page.waitForFunction( + (count) => document.querySelectorAll('[data-node-id]').length >= count, + expectedCount + ) + } else { + await this.page.waitForSelector('[data-node-id]') + } + } +} diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 8a52d8b66..4becc999c 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import type { NodeId } from '../../../src/schemas/comfyWorkflowSchema' +import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema' import { ManageGroupNode } from '../../helpers/manageGroupNode' import type { ComfyPage } from '../ComfyPage' import type { Position, Size } from '../types' @@ -134,7 +134,7 @@ export class SubgraphSlotReference { } } -export class NodeSlotReference { +class NodeSlotReference { constructor( readonly type: 'input' | 'output', readonly index: number, @@ -201,7 +201,7 @@ export class NodeSlotReference { } } -export class NodeWidgetReference { +class NodeWidgetReference { constructor( readonly index: number, readonly node: NodeReference diff --git a/browser_tests/helpers/templates.ts b/browser_tests/helpers/templates.ts index d659e125a..0d2c9f31e 100644 --- a/browser_tests/helpers/templates.ts +++ b/browser_tests/helpers/templates.ts @@ -4,7 +4,7 @@ import path from 'path' import { TemplateInfo, WorkflowTemplates -} from '../../src/types/workflowTemplateTypes' +} from '../../src/platform/workflow/templates/types/template' export class ComfyTemplates { readonly content: Locator diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index 8ac7449f4..cf2e5e6be 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -36,6 +36,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => { await comfyPage.loadWorkflow('missing/missing_nodes') await comfyPage.closeDialog() + // Wait for any async operations to complete after dialog closes + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(100) + // Make a change to the graph await comfyPage.doubleClickCanvas() await comfyPage.searchBox.fillAndSelectFirstNode('KSampler') diff --git a/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png index 3acc073ff..90bc677fc 100644 Binary files a/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png and b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png differ diff --git a/browser_tests/tests/extensionAPI.spec.ts b/browser_tests/tests/extensionAPI.spec.ts index 7711ccf3b..09a08384c 100644 --- a/browser_tests/tests/extensionAPI.spec.ts +++ b/browser_tests/tests/extensionAPI.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test' -import { SettingParams } from '../../src/types/settingTypes' +import { SettingParams } from '../../src/platform/settings/types' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Topbar commands', () => { @@ -247,7 +247,7 @@ test.describe('Topbar commands', () => { test.describe('Dialog', () => { test('Should allow showing a prompt dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .prompt({ title: 'Test Prompt', message: 'Test Prompt Message' @@ -267,7 +267,7 @@ test.describe('Topbar commands', () => { comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' @@ -284,7 +284,7 @@ test.describe('Topbar commands', () => { test('Should allow dismissing a dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { window['value'] = 'foo' - window['app'].extensionManager.dialog + void window['app'].extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index 68ce7b8d5..764849286 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -46,7 +46,7 @@ test.describe('Node Help', () => { // Click the help button in the selection toolbox const helpButton = comfyPage.selectionToolbox.locator( - 'button:has(.pi-question-circle)' + 'button[data-testid="info-button"]' ) await expect(helpButton).toBeVisible() await helpButton.click() @@ -164,7 +164,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -194,7 +194,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -228,7 +228,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -276,7 +276,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -348,7 +348,7 @@ This is documentation for a custom node. } const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) if (await helpButton.isVisible()) { await helpButton.click() @@ -389,7 +389,7 @@ This is documentation for a custom node. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -456,7 +456,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -479,7 +479,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -522,7 +522,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -538,7 +538,7 @@ This is English documentation. // Click help button again const helpButton2 = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton2.click() diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 4a390af96..05bb578df 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => { await comfyPage.page.keyboard.press('Control+A') await expect( - comfyPage.page.locator('.selection-toolbox .pi-refresh') + comfyPage.page.locator( + '.selection-toolbox button[data-testid="refresh-button"]' + ) ).toBeVisible() }) diff --git a/browser_tests/tests/rerouteNode.spec.ts-snapshots/native-reroute-chromium-linux.png b/browser_tests/tests/rerouteNode.spec.ts-snapshots/native-reroute-chromium-linux.png index 30b9a3894..11ecdabd0 100644 Binary files a/browser_tests/tests/rerouteNode.spec.ts-snapshots/native-reroute-chromium-linux.png and b/browser_tests/tests/rerouteNode.spec.ts-snapshots/native-reroute-chromium-linux.png differ diff --git a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png index d14a6e132..6e63295b8 100644 Binary files a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png and b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png index 9443182e3..2755d74c5 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts b/browser_tests/tests/selectionToolbox.spec.ts index ce45eb3fb..a9a5fc9c2 100644 --- a/browser_tests/tests/selectionToolbox.spec.ts +++ b/browser_tests/tests/selectionToolbox.spec.ts @@ -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(selectedNode.getProperty('color')).not.toBeNull() + expect(await selectedNode.getProperty('color')).not.toBeNull() }) test('color picker shows current color of selected nodes', async ({ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png index 7aa22906b..96f6507e1 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png index 41bb283d9..af92221f3 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png index 3cec1c675..f9b9b012c 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts new file mode 100644 index 000000000..a7311c15a --- /dev/null +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -0,0 +1,177 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Selection Toolbox - More Options Submenus', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.loadWorkflow('nodes/single_ksampler') + await comfyPage.nextFrame() + await comfyPage.selectNodes(['KSampler']) + await comfyPage.nextFrame() + }) + + const openMoreOptions = async (comfyPage: any) => { + const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler') + if (ksamplerNodes.length === 0) { + throw new Error('No KSampler nodes found') + } + + // Drag the KSampler to the center of the screen + const nodePos = await ksamplerNodes[0].getPosition() + const viewportSize = comfyPage.page.viewportSize() + const centerX = viewportSize.width / 3 + const centerY = viewportSize.height / 2 + await comfyPage.dragAndDrop( + { x: nodePos.x, y: nodePos.y }, + { x: centerX, y: centerY } + ) + await comfyPage.nextFrame() + + await ksamplerNodes[0].click('title') + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({ + timeout: 5000 + }) + + const moreOptionsBtn = comfyPage.page.locator( + '[data-testid="more-options-button"]' + ) + await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 }) + + await comfyPage.page.click('[data-testid="more-options-button"]') + + await comfyPage.nextFrame() + + const menuOptionsVisible = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisible) { + return + } + + await moreOptionsBtn.click({ force: true }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(2000) + + const menuOptionsVisibleAfterClick = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisibleAfterClick) { + return + } + + throw new Error('Could not open More Options menu - popover not showing') + } + + test('opens Node Info from More Options menu', async ({ comfyPage }) => { + await openMoreOptions(comfyPage) + const nodeInfoButton = comfyPage.page.getByText('Node Info', { + exact: true + }) + await expect(nodeInfoButton).toBeVisible() + await nodeInfoButton.click() + await comfyPage.nextFrame() + }) + + test('changes node shape via Shape submenu', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialShape = await nodeRef.getProperty('shape') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Shape', { exact: true }).click() + await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({ + timeout: 5000 + }) + await comfyPage.page.getByText('Box', { exact: true }).click() + await comfyPage.nextFrame() + + const newShape = await nodeRef.getProperty('shape') + expect(newShape).not.toBe(initialShape) + expect(newShape).toBe(1) + }) + + test('changes node color via Color submenu swatch', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialColor = await nodeRef.getProperty('color') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Color', { exact: true }).click() + const blueSwatch = comfyPage.page.locator('[title="Blue"]') + await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 }) + await blueSwatch.first().click() + await comfyPage.nextFrame() + + const newColor = await nodeRef.getProperty('color') + expect(newColor).toBe('#223') + if (initialColor) { + expect(newColor).not.toBe(initialColor) + } + }) + + test('renames a node using Rename action', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + await openMoreOptions(comfyPage) + await comfyPage.page + .getByText('Rename', { exact: true }) + .click({ force: true }) + const input = comfyPage.page.locator( + '.group-title-editor.node-title-editor .editable-text input' + ) + await expect(input).toBeVisible() + await input.fill('RenamedNode') + await input.press('Enter') + await comfyPage.nextFrame() + const newTitle = await nodeRef.getProperty('title') + expect(newTitle).toBe('RenamedNode') + }) + + test('closes More Options menu when clicking outside', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page + .locator('#graph-canvas') + .click({ position: { x: 0, y: 50 }, force: true }) + await comfyPage.nextFrame() + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) + + test('closes More Options menu when clicking the button again (toggle)', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page.evaluate(() => { + const btn = document.querySelector('[data-testid="more-options-button"]') + if (btn) { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + detail: 1 + }) + btn.dispatchEvent(event) + } + }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) +}) diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png index 5e105418c..ce88325aa 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-card-after-hover-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png index 4bafa8784..bf1d18934 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-card-before-hover-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-desktop-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-desktop-chromium-linux.png index ff6a6c017..0c34b0607 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-desktop-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-desktop-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png index 604c23351..54f14ea0d 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png index 1e2a1017c..a5cf133cb 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png index cabfe9a20..6c330c2f4 100644 Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-varying-content-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts new file mode 100644 index 000000000..a00d93eb0 --- /dev/null +++ b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts @@ -0,0 +1,141 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Vue Nodes - Delete Key Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + // Enable Vue nodes rendering + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setup() + }) + + test('Can select all and delete Vue nodes with Delete key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Select all Vue nodes + await comfyPage.ctrlA() + + // Verify all Vue nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(initialNodeCount) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify all Vue nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(0) + }) + + test('Can select specific Vue node and delete it', async ({ comfyPage }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Get first Vue node ID and select it + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Verify selection + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(1) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify one Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Can select and delete Vue node with Backspace key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Select first Vue node + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Delete with Backspace key instead of Delete + await comfyPage.vueNodes.deleteSelectedWithBackspace() + + // Verify Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Delete key does not delete node when typing in Vue node widgets', async ({ + comfyPage + }) => { + const initialNodeCount = await comfyPage.getGraphNodesCount() + + // Find a text input widget in a Vue node + const textWidget = comfyPage.page + .locator('input[type="text"], textarea') + .first() + + // Click on text widget to focus it + await textWidget.click() + await textWidget.fill('test text') + + // Press Delete while focused on widget - should delete text, not node + await textWidget.press('Delete') + + // Node count should remain the same + const finalNodeCount = await comfyPage.getGraphNodesCount() + expect(finalNodeCount).toBe(initialNodeCount) + }) + + test('Delete key does not delete node when nothing is selected', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Ensure no Vue nodes are selected + await comfyPage.vueNodes.clearSelection() + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(0) + + // Press Delete key - should not crash and should handle gracefully + await comfyPage.page.keyboard.press('Delete') + + // Vue node count should remain the same + const nodeCount = await comfyPage.vueNodes.getNodeCount() + expect(nodeCount).toBeGreaterThan(0) + }) + + test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Multi-select first two Vue nodes using Ctrl+click + const nodeIds = await comfyPage.vueNodes.getNodeIds() + const nodesToSelect = nodeIds.slice(0, 2) + await comfyPage.vueNodes.selectNodes(nodesToSelect) + + // Verify expected nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(nodesToSelect.length) + + // Delete selected Vue nodes + await comfyPage.vueNodes.deleteSelected() + + // Verify expected nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length) + }) +}) diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index b23faabfc..728b5d028 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -264,7 +264,13 @@ test.describe('Animated image widget', () => { expect(filename).toContain('animated_webp.webp') }) - test('Can preview saved animated webp image', async ({ comfyPage }) => { + // FIXME: This test keeps flip-flopping because it relies on animated webp timing, + // which is inherently unreliable in CI environments. The test asset is an animated + // webp with 2 frames, and the test depends on animation frame timing to verify that + // animated webp images are properly displayed (as opposed to being treated as static webp). + // While the underlying functionality works (animated webp are correctly distinguished + // from static webp), the test is flaky due to timing dependencies with webp animation frames. + test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/save_animated_webp') // Get position of the load animated webp node diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png deleted file mode 100644 index 3f3d831bb..000000000 Binary files a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png and /dev/null differ diff --git a/components.json b/components.json new file mode 100644 index 000000000..5526f900d --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/assets/css/style.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/docs/adr/0004-fork-primevue-ui-library.md b/docs/adr/0004-fork-primevue-ui-library.md new file mode 100644 index 000000000..02bd18736 --- /dev/null +++ b/docs/adr/0004-fork-primevue-ui-library.md @@ -0,0 +1,62 @@ +# 4. Fork PrimeVue UI Library + +Date: 2025-08-27 + +## Status + +Rejected + +## Context + +ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture: + +**Screen Coordinate Hit-Testing Conflicts:** +- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms +- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning +- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions + +**Virtual Canvas Scroll Interference:** +- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system +- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning +- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior + +**Historical Overlay Issues:** +- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely + +**Minimal Update Overhead:** +- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project + +**Future Interaction System Requirements:** +- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures +- Predictable need for additional component modifications beyond current identified issues + +## Decision + +We will **NOT** fork PrimeVue. After evaluation, forking was determined to be unnecessarily complex and costly. + +**Rationale for Rejection:** + +- **Significant Implementation Complexity**: PrimeVue is structured as a monorepo ([primefaces/primevue](https://github.com/primefaces/primevue)) with significant code in a separate monorepo ([PrimeUIX](https://github.com/primefaces/primeuix)). Forking would require importing both repositories whole and selectively pruning or exempting components from our workspace tooling, adding substantial complexity. + +- **Alternative Solutions Available**: The modifications we identified (e.g., scroll interference issues, coordinate system conflicts) have less costly solutions that don't require maintaining a full fork. For example, coordinate issues could be addressed through event interception and synthetic event creation with scaled values. + +- **Maintenance Burden**: Ongoing maintenance and upgrades would be very painful, requiring manual conflict resolution and keeping pace with upstream changes across multiple repositories. + +- **Limited Tooling Support**: There isn't adequate tooling that provides the granularity needed to cleanly manage a PrimeVue fork within our existing infrastructure. + +## Consequences + +### Alternative Approach + +- **Use PrimeVue as External Dependency**: Continue using PrimeVue as a standard npm dependency +- **Targeted Workarounds**: Implement specific solutions for identified issues (coordinate system conflicts, scroll interference) without forking the entire library +- **Selective Component Replacement**: Use libraries like shadcn/ui to replace specific problematic PrimeVue components and adjust them to match our design system +- **Upstream Engagement**: Continue engaging with PrimeVue community for feature requests and bug reports +- **Maintain Flexibility**: Preserve ability to upgrade PrimeVue versions without fork maintenance overhead + +## Notes + +- Technical issues documented in the Context section remain valid concerns +- Solutions will be pursued through targeted fixes rather than wholesale forking +- Future re-evaluation possible if PrimeVue's architecture significantly changes or if alternative tooling becomes available +- This decision prioritizes maintainability and development velocity over maximum customization control diff --git a/docs/adr/README.md b/docs/adr/README.md index 00e50a639..5f6e5c2cf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,7 @@ An Architecture Decision Record captures an important architectural decision mad | [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 | | [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 | | [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 | +| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 | ## Creating a New ADR diff --git a/eslint.config.js b/eslint.config.js index fe0f56b3b..cddba3bbd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,6 +64,42 @@ export default [ 'vue/no-v-html': 'off', // Enforce dark-theme: instead of dark: prefix 'vue/no-restricted-class': ['error', '/^dark:/'], + 'vue/multi-word-component-names': 'off', // TODO: fix + 'vue/no-template-shadow': 'off', // TODO: fix + 'vue/one-component-per-file': 'off', // TODO: fix + // Restrict deprecated PrimeVue components + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'primevue/calendar', + message: + 'Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from "primevue/datepicker"' + }, + { + name: 'primevue/dropdown', + message: + 'Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from "primevue/select"' + }, + { + name: 'primevue/inputswitch', + message: + 'InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from "primevue/toggleswitch"' + }, + { + name: 'primevue/overlaypanel', + message: + 'OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from "primevue/popover"' + }, + { + name: 'primevue/sidebar', + message: + 'Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from "primevue/drawer"' + } + ] + } + ], // i18n rules '@intlify/vue-i18n/no-raw-text': [ 'error', diff --git a/knip.config.ts b/knip.config.ts index 2c1167ab3..9df077d77 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,84 +2,56 @@ import type { KnipConfig } from 'knip' const config: KnipConfig = { entry: [ - 'build/**/*.ts', - 'scripts/**/*.{js,ts}', + '{build,scripts}/**/*.{js,ts}', + 'src/assets/css/style.css', 'src/main.ts', - 'vite.electron.config.mts', - 'vite.types.config.mts' - ], - project: [ - 'browser_tests/**/*.{js,ts}', - 'build/**/*.{js,ts,vue}', - 'scripts/**/*.{js,ts}', - 'src/**/*.{js,ts,vue}', - 'tests-ui/**/*.{js,ts,vue}', - '*.{js,ts,mts}' + 'src/scripts/ui/menu/index.ts', + 'src/types/index.ts' ], + project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'], ignoreBinaries: ['only-allow', 'openapi-typescript'], ignoreDependencies: [ + // Weird importmap things + '@iconify/json', '@primeuix/forms', '@primeuix/styled', '@primeuix/utils', '@primevue/icons', - '@iconify/json', - 'tailwindcss', - 'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying // Dev '@trivago/prettier-plugin-sort-imports' ], ignore: [ - // Generated files - 'dist/**', - 'types/**', - 'node_modules/**', - // Config files that might not show direct usage - '.husky/**', - // Temporary or cache files - '.vite/**', - 'coverage/**', - // i18n config - '.i18nrc.cjs', - // Vitest litegraph config - 'vitest.litegraph.config.ts', - // Test setup files - 'browser_tests/globalSetup.ts', - 'browser_tests/globalTeardown.ts', - 'browser_tests/utils/**', - // Scripts - 'scripts/**', - // Vite config files - 'vite.electron.config.mts', - 'vite.types.config.mts', // Auto generated manager types 'src/types/generatedManagerTypes.ts', - // Design system components (may not be used immediately) - 'src/components/button/IconGroup.vue', - 'src/components/button/MoreButton.vue', - 'src/components/button/TextButton.vue', - 'src/components/card/CardTitle.vue', - 'src/components/card/CardDescription.vue', - 'src/components/input/SingleSelect.vue', + 'src/types/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) - 'src/scripts/ui/components/splitButton.ts', - // Generated file: openapi - 'src/types/comfyRegistryTypes.ts' + 'src/scripts/ui/components/splitButton.ts' ], - ignoreExportsUsedInFile: true, - // Vue-specific configuration - vue: true, - tailwind: true, - // Only check for unused files, disable all other rules - // TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888 - rules: { - classMembers: 'off' + compilers: { + // https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199 + css: (text: string) => + [ + ...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g) + ].join('\n') + }, + vite: { + config: ['vite?(.*).config.mts'] + }, + vitest: { + config: ['vitest?(.*).config.ts'], + entry: [ + '**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)', + '**/__mocks__/**/*.[jt]s?(x)' + ] + }, + playwright: { + config: ['playwright?(.*).config.ts'], + entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts'] }, tags: [ '-knipIgnoreUnusedButUsedByCustomNodes', '-knipIgnoreUnusedButUsedByVueNodesBranch' - ], - // Include dependencies analysis - includeEntryExports: true + ] } export default config diff --git a/package.json b/package.json index 68e182f1b..e568820c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.27.1", + "version": "1.27.4", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -25,8 +25,8 @@ "preinstall": "npx only-allow pnpm", "prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", "preview": "nx preview", - "lint": "eslint src --cache --concurrency=auto", - "lint:fix": "eslint src --cache --fix --concurrency=auto", + "lint": "eslint src --cache", + "lint:fix": "eslint src --cache --fix", "lint:no-cache": "eslint src", "lint:fix:no-cache": "eslint src --fix", "knip": "knip --cache", @@ -39,6 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.8.0", + "@iconify-json/lucide": "^1.2.66", "@iconify/tailwind": "^1.2.0", "@intlify/eslint-plugin-vue-i18n": "^3.2.0", "@lobehub/i18n-cli": "^1.25.1", @@ -76,13 +77,13 @@ "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", "tailwindcss": "^4.1.12", "tailwindcss-primeui": "^0.6.1", "tsx": "^4.15.6", + "tw-animate-css": "^1.3.8", "typescript": "^5.4.5", "typescript-eslint": "^8.42.0", "unplugin-icons": "^0.22.0", @@ -100,7 +101,7 @@ "dependencies": { "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", - "@comfyorg/comfyui-electron-types": "^0.4.69", + "@comfyorg/comfyui-electron-types": "0.4.73-0", "@iconify/json": "^2.2.380", "@primeuix/forms": "0.0.2", "@primeuix/styled": "0.3.2", @@ -140,6 +141,7 @@ "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.2.5", + "reka-ui": "^2.5.0", "semver": "^7.7.2", "tailwind-merge": "^3.3.1", "three": "^0.170.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbfd7c1b..acc01cc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.3.1 version: 1.3.1 '@comfyorg/comfyui-electron-types': - specifier: ^0.4.69 - version: 0.4.69 + specifier: 0.4.73-0 + version: 0.4.73-0 '@iconify/json': specifier: ^2.2.380 version: 2.2.380 @@ -134,6 +134,9 @@ importers: primevue: specifier: ^4.2.5 version: 4.2.5(vue@3.5.13(typescript@5.9.2)) + reka-ui: + specifier: ^2.5.0 + version: 2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)) semver: specifier: ^7.7.2 version: 7.7.2 @@ -171,6 +174,9 @@ importers: '@eslint/js': specifier: ^9.8.0 version: 9.12.0 + '@iconify-json/lucide': + specifier: ^1.2.66 + version: 1.2.66 '@iconify/tailwind': specifier: ^1.2.0 version: 1.2.0 @@ -282,9 +288,6 @@ importers: lint-staged: specifier: ^15.2.7 version: 15.2.7 - lucide-vue-next: - specifier: ^0.540.0 - version: 0.540.0(vue@3.5.13(typescript@5.9.2)) nx: specifier: 21.4.1 version: 21.4.1 @@ -303,6 +306,9 @@ importers: tsx: specifier: ^4.15.6 version: 4.19.4 + tw-animate-css: + specifier: ^1.3.8 + version: 1.3.8 typescript: specifier: ^5.4.5 version: 5.9.2 @@ -980,8 +986,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@comfyorg/comfyui-electron-types@0.4.69': - resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==} + '@comfyorg/comfyui-electron-types@0.4.73-0': + resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==} '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -1570,6 +1576,18 @@ packages: '@firebase/webchannel-wrapper@1.0.3': resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@floating-ui/vue@1.1.9': + resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@grpc/grpc-js@1.9.15': resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} engines: {node: ^8.13.0 || >=10.10.0} @@ -1595,6 +1613,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/lucide@1.2.66': + resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==} + '@iconify/json@2.2.380': resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==} @@ -1607,6 +1628,12 @@ packages: '@iconify/utils@2.3.0': resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} + '@internationalized/date@3.9.0': + resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + '@intlify/core-base@9.14.3': resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==} engines: {node: '>= 16'} @@ -2240,6 +2267,9 @@ packages: storybook: ^9.1.1 vue: ^3.0.0 + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/node@4.1.12': resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==} @@ -2330,6 +2360,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + + '@tanstack/vue-virtual@3.13.12': + resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2606,6 +2644,9 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.20': resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==} @@ -2856,12 +2897,21 @@ packages: '@vueuse/core@11.0.0': resolution: {integrity: sha512-shibzNGjmRjZucEm97B8V0NO5J3vPHMCE/mltxQ3vHezbDoFQBMtK11XsfwfPionxSbo+buqPmsCljtYuXIBpw==} + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + '@vueuse/metadata@11.0.0': resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==} + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + '@vueuse/shared@11.0.0': resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==} + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@webgpu/types@0.1.51': resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==} @@ -3015,6 +3065,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -3485,6 +3539,9 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -4736,11 +4793,6 @@ packages: resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} engines: {node: '>=16.14'} - lucide-vue-next@0.540.0: - resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==} - peerDependencies: - vue: '>=3.0.1' - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5107,6 +5159,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5557,6 +5612,11 @@ packages: resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} hasBin: true + reka-ui@2.5.0: + resolution: {integrity: sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==} + peerDependencies: + vue: '>= 3.2.0' + relateurl@0.2.7: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} @@ -5991,6 +6051,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tw-animate-css@1.3.8: + resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6293,8 +6356,8 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.0.6: - resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} + vue-component-type-helpers@3.0.7: + resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -7439,7 +7502,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@comfyorg/comfyui-electron-types@0.4.69': {} + '@comfyorg/comfyui-electron-types@0.4.73-0': {} '@csstools/color-helpers@5.1.0': {} @@ -8001,6 +8064,26 @@ snapshots: '@firebase/webchannel-wrapper@1.0.3': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@floating-ui/vue@1.1.9(vue@3.5.13(typescript@5.9.2))': + dependencies: + '@floating-ui/dom': 1.7.4 + '@floating-ui/utils': 0.2.10 + vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.13 @@ -8024,6 +8107,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/lucide@1.2.66': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/json@2.2.380': dependencies: '@iconify/types': 2.0.0 @@ -8048,6 +8135,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@internationalized/date@3.9.0': + dependencies: + '@swc/helpers': 0.5.17 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.17 + '@intlify/core-base@9.14.3': dependencies: '@intlify/message-compiler': 9.14.3 @@ -8830,7 +8925,11 @@ snapshots: storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.2) - vue-component-type-helpers: 3.0.6 + vue-component-type-helpers: 3.0.7 + + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 '@tailwindcss/node@4.1.12': dependencies: @@ -8903,6 +9002,13 @@ snapshots: tailwindcss: 4.1.12 vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2) + '@tanstack/virtual-core@3.13.12': {} + + '@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.2))': + dependencies: + '@tanstack/virtual-core': 3.13.12 + vue: 3.5.13(typescript@5.9.2) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -9210,6 +9316,8 @@ snapshots: '@types/web-bluetooth@0.0.20': {} + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.20': {} '@typescript-eslint/eslint-plugin@8.42.0(@typescript-eslint/parser@8.42.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)': @@ -9616,8 +9724,19 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/core@12.8.2(typescript@5.9.2)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.2) + vue: 3.5.13(typescript@5.9.2) + transitivePeerDependencies: + - typescript + '@vueuse/metadata@11.0.0': {} + '@vueuse/metadata@12.8.2': {} + '@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))': dependencies: vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2)) @@ -9625,6 +9744,12 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/shared@12.8.2(typescript@5.9.2)': + dependencies: + vue: 3.5.13(typescript@5.9.2) + transitivePeerDependencies: + - typescript + '@webgpu/types@0.1.51': {} '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': @@ -9773,6 +9898,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -10242,6 +10371,8 @@ snapshots: define-lazy-prop@3.0.0: {} + defu@6.1.4: {} + delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -11563,10 +11694,6 @@ snapshots: lru-cache@8.0.5: {} - lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)): - dependencies: - vue: 3.5.13(typescript@5.9.2) - lz-string@1.5.0: {} magic-string@0.30.17: @@ -12137,6 +12264,8 @@ snapshots: object-keys@1.1.1: {} + ohash@2.0.11: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -12713,6 +12842,23 @@ snapshots: dependencies: jsesc: 3.0.2 + reka-ui@2.5.0(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)): + dependencies: + '@floating-ui/dom': 1.7.4 + '@floating-ui/vue': 1.1.9(vue@3.5.13(typescript@5.9.2)) + '@internationalized/date': 3.9.0 + '@internationalized/number': 3.6.5 + '@tanstack/vue-virtual': 3.13.12(vue@3.5.13(typescript@5.9.2)) + '@vueuse/core': 12.8.2(typescript@5.9.2) + '@vueuse/shared': 12.8.2(typescript@5.9.2) + aria-hidden: 1.2.6 + defu: 6.1.4 + ohash: 2.0.11 + vue: 3.5.13(typescript@5.9.2) + transitivePeerDependencies: + - '@vue/composition-api' + - typescript + relateurl@0.2.7: {} remark-frontmatter@5.0.0: @@ -13160,6 +13306,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tw-animate-css@1.3.8: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -13506,7 +13654,7 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.0.6: {} + vue-component-type-helpers@3.0.7: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): dependencies: diff --git a/scripts/cicd/extract-playwright-counts.ts b/scripts/cicd/extract-playwright-counts.ts new file mode 100755 index 000000000..ff6f44db3 --- /dev/null +++ b/scripts/cicd/extract-playwright-counts.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env tsx +import fs from 'fs' +import path from 'path' + +interface TestStats { + expected?: number + unexpected?: number + flaky?: number + skipped?: number + finished?: number +} + +interface ReportData { + stats?: TestStats +} + +interface TestCounts { + passed: number + failed: number + flaky: number + skipped: number + total: number +} + +/** + * Extract test counts from Playwright HTML report + * @param reportDir - Path to the playwright-report directory + * @returns Test counts { passed, failed, flaky, skipped, total } + */ +function extractTestCounts(reportDir: string): TestCounts { + const counts: TestCounts = { + passed: 0, + failed: 0, + flaky: 0, + skipped: 0, + total: 0 + } + + try { + // First, try to find report.json which Playwright generates with JSON reporter + const jsonReportFile = path.join(reportDir, 'report.json') + if (fs.existsSync(jsonReportFile)) { + const reportJson: ReportData = JSON.parse( + fs.readFileSync(jsonReportFile, 'utf-8') + ) + if (reportJson.stats) { + const stats = reportJson.stats + counts.total = stats.expected || 0 + counts.passed = + (stats.expected || 0) - + (stats.unexpected || 0) - + (stats.flaky || 0) - + (stats.skipped || 0) + counts.failed = stats.unexpected || 0 + counts.flaky = stats.flaky || 0 + counts.skipped = stats.skipped || 0 + return counts + } + } + + // Try index.html - Playwright HTML report embeds data in a script tag + const indexFile = path.join(reportDir, 'index.html') + if (fs.existsSync(indexFile)) { + const content = fs.readFileSync(indexFile, 'utf-8') + + // Look for the embedded report data in various formats + // Format 1: window.playwrightReportBase64 + let dataMatch = content.match( + /window\.playwrightReportBase64\s*=\s*["']([^"']+)["']/ + ) + if (dataMatch) { + try { + const decodedData = Buffer.from(dataMatch[1], 'base64').toString( + 'utf-8' + ) + const reportData: ReportData = JSON.parse(decodedData) + + if (reportData.stats) { + const stats = reportData.stats + counts.total = stats.expected || 0 + counts.passed = + (stats.expected || 0) - + (stats.unexpected || 0) - + (stats.flaky || 0) - + (stats.skipped || 0) + counts.failed = stats.unexpected || 0 + counts.flaky = stats.flaky || 0 + counts.skipped = stats.skipped || 0 + return counts + } + } catch (e) { + // Continue to try other formats + } + } + + // Format 2: window.playwrightReport + dataMatch = content.match(/window\.playwrightReport\s*=\s*({[\s\S]*?});/) + if (dataMatch) { + try { + // Use Function constructor instead of eval for safety + const reportData = new Function( + 'return ' + dataMatch[1] + )() as ReportData + + if (reportData.stats) { + const stats = reportData.stats + counts.total = stats.expected || 0 + counts.passed = + (stats.expected || 0) - + (stats.unexpected || 0) - + (stats.flaky || 0) - + (stats.skipped || 0) + counts.failed = stats.unexpected || 0 + counts.flaky = stats.flaky || 0 + counts.skipped = stats.skipped || 0 + return counts + } + } catch (e) { + // Continue to try other formats + } + } + + // Format 3: Look for stats in the HTML content directly + // Playwright sometimes renders stats in the UI + const statsMatch = content.match( + /(\d+)\s+passed[^0-9]*(\d+)\s+failed[^0-9]*(\d+)\s+flaky[^0-9]*(\d+)\s+skipped/i + ) + if (statsMatch) { + counts.passed = parseInt(statsMatch[1]) || 0 + counts.failed = parseInt(statsMatch[2]) || 0 + counts.flaky = parseInt(statsMatch[3]) || 0 + counts.skipped = parseInt(statsMatch[4]) || 0 + counts.total = + counts.passed + counts.failed + counts.flaky + counts.skipped + return counts + } + + // Format 4: Try to extract from summary text patterns + const passedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+passed/i) + const failedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+failed/i) + const flakyMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+flaky/i) + const skippedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+skipped/i) + const totalMatch = content.match( + /(\d+)\s+(?:tests?|specs?)\s+(?:total|ran)/i + ) + + if (passedMatch) counts.passed = parseInt(passedMatch[1]) || 0 + if (failedMatch) counts.failed = parseInt(failedMatch[1]) || 0 + if (flakyMatch) counts.flaky = parseInt(flakyMatch[1]) || 0 + if (skippedMatch) counts.skipped = parseInt(skippedMatch[1]) || 0 + if (totalMatch) { + counts.total = parseInt(totalMatch[1]) || 0 + } else if ( + counts.passed || + counts.failed || + counts.flaky || + counts.skipped + ) { + counts.total = + counts.passed + counts.failed + counts.flaky + counts.skipped + } + } + } catch (error) { + console.error(`Error reading report from ${reportDir}:`, error) + } + + return counts +} + +// Main execution +const reportDir = process.argv[2] + +if (!reportDir) { + console.error('Usage: extract-playwright-counts.ts ') + process.exit(1) +} + +const counts = extractTestCounts(reportDir) + +// Output as JSON for easy parsing in shell script +console.log(JSON.stringify(counts)) + +export { extractTestCounts } diff --git a/scripts/cicd/pr-playwright-deploy-and-comment.sh b/scripts/cicd/pr-playwright-deploy-and-comment.sh new file mode 100755 index 000000000..aeab37c8e --- /dev/null +++ b/scripts/cicd/pr-playwright-deploy-and-comment.sh @@ -0,0 +1,377 @@ +#!/bin/bash +set -e + +# Deploy Playwright test reports to Cloudflare Pages and comment on PR +# Usage: ./pr-playwright-deploy-and-comment.sh [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="" +# 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 + +# Check if tsx is available, install if not +if ! command -v tsx > /dev/null 2>&1; then + echo "Installing tsx..." >&2 + npm install -g tsx >&2 || echo "Failed to install tsx" >&2 +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 < **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 + test counts + temp_dir=$(mktemp -d) + pids="" + i=0 + + # Store current working directory for absolute paths + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + BASE_DIR="$(pwd)" + + # Start parallel deployments and count extractions + 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" + + # Extract test counts using tsx (TypeScript executor) + EXTRACT_SCRIPT="$SCRIPT_DIR/extract-playwright-counts.ts" + REPORT_DIR="$BASE_DIR/reports/playwright-report-$browser" + + if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then + echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2 + counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}') + echo "Extracted counts for $browser: $counts" >&2 + echo "$counts" > "$temp_dir/$i.counts" + else + echo "Script not found or tsx not available: $EXTRACT_SCRIPT" >&2 + echo '{}' > "$temp_dir/$i.counts" + fi + ) & + pids="$pids $!" + else + echo "Report not found for $browser at reports/playwright-report-$browser" + echo "failed" > "$temp_dir/$i.url" + echo '{}' > "$temp_dir/$i.counts" + fi + i=$((i + 1)) + done + + # Wait for all deployments to complete + for pid in $pids; do + wait $pid + done + + # Collect URLs and counts in order + urls="" + all_counts="" + 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 + + if [ -f "$temp_dir/$i.counts" ]; then + counts=$(cat "$temp_dir/$i.counts") + echo "Read counts for $browser from $temp_dir/$i.counts: $counts" >&2 + else + counts="{}" + echo "No counts file found for $browser at $temp_dir/$i.counts" >&2 + fi + if [ -z "$all_counts" ]; then + all_counts="$counts" + else + all_counts="$all_counts|$counts" + fi + + i=$((i + 1)) + done + + # Clean up temp directory + rm -rf "$temp_dir" + + # Calculate total test counts across all browsers + total_passed=0 + total_failed=0 + total_flaky=0 + total_skipped=0 + total_tests=0 + + # Parse counts and calculate totals + IFS='|' + set -- $all_counts + for counts_json; do + if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then + # Parse JSON counts using simple grep/sed if jq is not available + if command -v jq > /dev/null 2>&1; then + passed=$(echo "$counts_json" | jq -r '.passed // 0') + failed=$(echo "$counts_json" | jq -r '.failed // 0') + flaky=$(echo "$counts_json" | jq -r '.flaky // 0') + skipped=$(echo "$counts_json" | jq -r '.skipped // 0') + total=$(echo "$counts_json" | jq -r '.total // 0') + else + # Fallback parsing without jq + passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p') + failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p') + flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p') + skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p') + total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p') + fi + + total_passed=$((total_passed + ${passed:-0})) + total_failed=$((total_failed + ${failed:-0})) + total_flaky=$((total_flaky + ${flaky:-0})) + total_skipped=$((total_skipped + ${skipped:-0})) + total_tests=$((total_tests + ${total:-0})) + fi + done + unset IFS + + # Determine overall status + if [ $total_failed -gt 0 ]; then + status_icon="โŒ" + status_text="Some tests failed" + elif [ $total_flaky -gt 0 ]; then + status_icon="โš ๏ธ" + status_text="Tests passed with flaky tests" + elif [ $total_tests -gt 0 ]; then + status_icon="โœ…" + status_text="All tests passed!" + else + status_icon="๐Ÿ•ต๐Ÿป" + status_text="No test results found" + fi + + # Generate completion comment + comment="$COMMENT_MARKER +## ๐ŸŽญ Playwright Test Results + +$status_icon **$status_text** + +โฐ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC" + + # Add summary counts if we have test data + if [ $total_tests -gt 0 ]; then + comment="$comment + +### ๐Ÿ“ˆ Summary +- **Total Tests:** $total_tests +- **Passed:** $total_passed โœ… +- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo 'โŒ' || echo '') +- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo 'โš ๏ธ' || echo '') +- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo 'โญ๏ธ' || echo '')" + fi + + comment="$comment + +### ๐Ÿ“Š Test Reports by Browser" + + # Add browser results with individual counts + i=0 + IFS='|' + set -- $all_counts + for counts_json; do + # Get browser name + browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1))) + # Get URL at position i + url=$(echo "$urls" | cut -d' ' -f$((i + 1))) + + if [ "$url" != "failed" ] && [ -n "$url" ]; then + # Parse individual browser counts + if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then + if command -v jq > /dev/null 2>&1; then + b_passed=$(echo "$counts_json" | jq -r '.passed // 0') + b_failed=$(echo "$counts_json" | jq -r '.failed // 0') + b_flaky=$(echo "$counts_json" | jq -r '.flaky // 0') + b_skipped=$(echo "$counts_json" | jq -r '.skipped // 0') + b_total=$(echo "$counts_json" | jq -r '.total // 0') + else + b_passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p') + b_failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p') + b_flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p') + b_skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p') + b_total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p') + fi + + if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then + counts_str=" โ€ข โœ… $b_passed / โŒ $b_failed / โš ๏ธ $b_flaky / โญ๏ธ $b_skipped" + else + counts_str="" + fi + else + counts_str="" + fi + + comment="$comment +- โœ… **${browser}**: [View Report](${url})${counts_str}" + else + comment="$comment +- โŒ **${browser}**: Deployment failed" + fi + i=$((i + 1)) + done + unset IFS + + comment="$comment + +--- +๐ŸŽ‰ Click on the links above to view detailed test results for each browser configuration." + + post_comment "$comment" +fi \ No newline at end of file diff --git a/scripts/collect-i18n-general.ts b/scripts/collect-i18n-general.ts index 63d97d530..f0b6dde0c 100644 --- a/scripts/collect-i18n-general.ts +++ b/scripts/collect-i18n-general.ts @@ -3,8 +3,8 @@ import * as fs from 'fs' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands' import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig' +import type { FormItem, SettingParams } from '../src/platform/settings/types' import type { ComfyCommandImpl } from '../src/stores/commandStore' -import type { FormItem, SettingParams } from '../src/types/settingTypes' import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil' const localePath = './src/locales/en/main.json' diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 76aa41b2e..1a2056626 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -5,7 +5,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema' import { zComfyWorkflow, zComfyWorkflow1 -} from '../src/schemas/comfyWorkflowSchema' +} from '../src/platform/workflow/validation/schemas/workflowSchema' import { zComfyNodeDef as zComfyNodeDefV2 } from '../src/schemas/nodeDef/nodeDefSchemaV2' import { zComfyNodeDef as zComfyNodeDefV1 } from '../src/schemas/nodeDefSchema' diff --git a/src/assets/css/style.css b/src/assets/css/style.css index ee6e697f0..70b6bf0d3 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -2,71 +2,12 @@ @import 'tailwindcss/theme' layer(theme); @import 'tailwindcss/utilities' layer(utilities); +@import 'tw-animate-css'; -@plugin "tailwindcss-primeui"; +@plugin 'tailwindcss-primeui'; @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; @@ -107,6 +48,100 @@ } } +@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-bypass: #6A246A; + --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); +} + +@theme inline { + --color-node-component-surface: var(--color-charcoal-300); + --color-node-component-surface-highlight: var(--color-slate-100); + --color-node-component-surface-hovered: var(--color-charcoal-500); + --color-node-component-surface-selected: var(--color-charcoal-700); + --color-node-stroke: var(--color-stone-100); +} + +@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; @@ -849,7 +884,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; @@ -862,7 +897,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; @@ -927,9 +962,7 @@ audio.comfy-audio.empty-audio-widget { /* Uses default styling - no overrides needed */ } -/* Smooth transitions between LOD levels */ .lg-node { - transition: min-height 0.2s ease; /* Disable text selection on all nodes */ user-select: none; -webkit-user-select: none; @@ -939,7 +972,9 @@ 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 */ @@ -971,4 +1006,3 @@ audio.comfy-audio.empty-audio-widget { /* Use solid colors only */ background-image: none !important; } - diff --git a/src/assets/icons/custom/mask.svg b/src/assets/icons/custom/mask.svg new file mode 100644 index 000000000..1e1a6d97c --- /dev/null +++ b/src/assets/icons/custom/mask.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/base/common/downloadUtil.ts b/src/base/common/downloadUtil.ts new file mode 100644 index 000000000..307a3e35b --- /dev/null +++ b/src/base/common/downloadUtil.ts @@ -0,0 +1,41 @@ +/** + * Utility functions for downloading files + */ + +// Constants +const DEFAULT_DOWNLOAD_FILENAME = 'download.png' + +/** + * Download a file from a URL by creating a temporary anchor element + * @param url - The URL of the file to download (must be a valid URL string) + * @param filename - Optional filename override (will use URL filename or default if not provided) + * @throws {Error} If the URL is invalid or empty + */ +export const downloadFile = (url: string, filename?: string): void => { + if (!url || typeof url !== 'string' || url.trim().length === 0) { + throw new Error('Invalid URL provided for download') + } + const link = document.createElement('a') + link.href = url + link.download = + filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME + + // Trigger download + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +/** + * Extract filename from a URL's query parameters + * @param url - The URL to extract filename from + * @returns The extracted filename or null if not found + */ +const extractFilenameFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin) + return urlObj.searchParams.get('filename') + } catch { + return null + } +} diff --git a/src/components/LiteGraphCanvasSplitterOverlay.vue b/src/components/LiteGraphCanvasSplitterOverlay.vue index bd5070250..6e3cd5842 100644 --- a/src/components/LiteGraphCanvasSplitterOverlay.vue +++ b/src/components/LiteGraphCanvasSplitterOverlay.vue @@ -50,7 +50,7 @@ import Splitter from 'primevue/splitter' import SplitterPanel from 'primevue/splitterpanel' import { computed } from 'vue' -import { useSettingStore } from '@/stores/settingStore' +import { useSettingStore } from '@/platform/settings/settingStore' import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' diff --git a/src/components/MenuHamburger.vue b/src/components/MenuHamburger.vue index 58c725763..b46c27e27 100644 --- a/src/components/MenuHamburger.vue +++ b/src/components/MenuHamburger.vue @@ -23,8 +23,8 @@ import Button from 'primevue/button' import { CSSProperties, computed, watchEffect } from 'vue' +import { useSettingStore } from '@/platform/settings/settingStore' import { app } from '@/scripts/app' -import { useSettingStore } from '@/stores/settingStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { showNativeSystemMenu } from '@/utils/envUtil' diff --git a/src/components/actionbar/BatchCountEdit.vue b/src/components/actionbar/BatchCountEdit.vue index 281b886df..603ca6067 100644 --- a/src/components/actionbar/BatchCountEdit.vue +++ b/src/components/actionbar/BatchCountEdit.vue @@ -37,8 +37,8 @@ import { storeToRefs } from 'pinia' import InputNumber from 'primevue/inputnumber' import { computed } from 'vue' +import { useSettingStore } from '@/platform/settings/settingStore' import { useQueueSettingsStore } from '@/stores/queueStore' -import { useSettingStore } from '@/stores/settingStore' const queueSettingsStore = useQueueSettingsStore() const { batchCount } = storeToRefs(queueSettingsStore) diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index 24b3f8858..589815dc8 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -24,7 +24,7 @@ import { clamp } from 'es-toolkit/compat' import Panel from 'primevue/panel' import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue' -import { useSettingStore } from '@/stores/settingStore' +import { useSettingStore } from '@/platform/settings/settingStore' import ComfyQueueButton from './ComfyQueueButton.vue' @@ -37,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled') const topMenuRef = inject>('topMenuRef') const panelRef = ref(null) const dragHandleRef = ref(null) -const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false) +const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true) const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', { x: 0, y: 0 diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index 62741a4e5..52cb75a7f 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -3,13 +3,37 @@
+
diff --git a/src/components/graph/selectionToolbox/ExtensionCommandButton.vue b/src/components/graph/selectionToolbox/ExtensionCommandButton.vue index 3dc84f710..92cd09e38 100644 --- a/src/components/graph/selectionToolbox/ExtensionCommandButton.vue +++ b/src/components/graph/selectionToolbox/ExtensionCommandButton.vue @@ -7,6 +7,7 @@ }" severity="secondary" text + icon-class="w-4 h-4" :icon="typeof command.icon === 'function' ? command.icon() : command.icon" @click="() => commandStore.execute(command.id)" /> diff --git a/src/components/graph/selectionToolbox/FrameNodes.vue b/src/components/graph/selectionToolbox/FrameNodes.vue new file mode 100644 index 000000000..6d800a16f --- /dev/null +++ b/src/components/graph/selectionToolbox/FrameNodes.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/HelpButton.vue b/src/components/graph/selectionToolbox/HelpButton.vue deleted file mode 100644 index e77701bd4..000000000 --- a/src/components/graph/selectionToolbox/HelpButton.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/InfoButton.spec.ts b/src/components/graph/selectionToolbox/InfoButton.spec.ts new file mode 100644 index 000000000..da2a13831 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.spec.ts @@ -0,0 +1,149 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import PrimeVue from 'primevue/config' +import Tooltip from 'primevue/tooltip' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue' +// NOTE: The component import must come after mocks so they take effect. +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' + +const mockLGraphNode = { + type: 'TestNode', + title: 'Test Node' +} + +vi.mock('@/utils/litegraphUtil', () => ({ + isLGraphNode: vi.fn(() => true) +})) + +vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ + useNodeLibrarySidebarTab: () => ({ + id: 'node-library' + }) +})) + +const openHelpMock = vi.fn() +const closeHelpMock = vi.fn() +const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null } +vi.mock('@/stores/workspace/nodeHelpStore', () => ({ + useNodeHelpStore: () => ({ + openHelp: (def: any) => { + nodeHelpState.currentHelpNode = def + openHelpMock(def) + }, + closeHelp: () => { + nodeHelpState.currentHelpNode = null + closeHelpMock() + }, + get currentHelpNode() { + return nodeHelpState.currentHelpNode + }, + get isHelpOpen() { + return nodeHelpState.currentHelpNode !== null + } + }) +})) + +const toggleSidebarTabMock = vi.fn((id: string) => { + sidebarState.activeSidebarTabId = + sidebarState.activeSidebarTabId === id ? null : id +}) +const sidebarState: { activeSidebarTabId: string | null } = { + activeSidebarTabId: 'other-tab' +} +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: () => ({ + get activeSidebarTabId() { + return sidebarState.activeSidebarTabId + }, + toggleSidebarTab: toggleSidebarTabMock + }) +})) + +describe('InfoButton', () => { + let canvasStore: ReturnType + let nodeDefStore: ReturnType + + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + info: 'Node Info' + } + } + } + }) + + beforeEach(() => { + setActivePinia(createPinia()) + canvasStore = useCanvasStore() + nodeDefStore = useNodeDefStore() + + vi.clearAllMocks() + }) + + const mountComponent = () => { + return mount(InfoButton, { + global: { + plugins: [i18n, PrimeVue], + directives: { tooltip: Tooltip }, + stubs: { + 'i-lucide:info': true, + Button: { + template: + '', + props: ['severity', 'text', 'class'], + emits: ['click'] + } + } + } + }) + } + + it('should handle click without errors', async () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + const wrapper = mountComponent() + const button = wrapper.find('button') + await button.trigger('click') + expect(button.exists()).toBe(true) + }) + + it('should have correct CSS classes', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.classes()).toContain('help-button') + expect(button.attributes('severity')).toBe('secondary') + }) + + it('should have correct tooltip', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.exists()).toBe(true) + }) +}) diff --git a/src/components/graph/selectionToolbox/InfoButton.vue b/src/components/graph/selectionToolbox/InfoButton.vue new file mode 100644 index 000000000..3fd159d89 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index b207e5018..5187f0c02 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,6 +1,5 @@ diff --git a/src/components/graph/selectionToolbox/MoreOptions.vue b/src/components/graph/selectionToolbox/MoreOptions.vue new file mode 100644 index 000000000..f40a49b60 --- /dev/null +++ b/src/components/graph/selectionToolbox/MoreOptions.vue @@ -0,0 +1,316 @@ + + + diff --git a/src/components/graph/selectionToolbox/PinButton.vue b/src/components/graph/selectionToolbox/PinButton.vue deleted file mode 100644 index 86598339b..000000000 --- a/src/components/graph/selectionToolbox/PinButton.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index 786fe511f..0da7364a0 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -1,17 +1,22 @@ diff --git a/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue new file mode 100644 index 000000000..7f76d2eab --- /dev/null +++ b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue new file mode 100644 index 000000000..056f0f90b --- /dev/null +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/graph/selectionToolbox/VerticalDivider.vue b/src/components/graph/selectionToolbox/VerticalDivider.vue new file mode 100644 index 000000000..dc6876a3e --- /dev/null +++ b/src/components/graph/selectionToolbox/VerticalDivider.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 11cfafa1c..305af0621 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -23,10 +23,10 @@ import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue' import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition' import { useDomClipping } from '@/composables/element/useDomClipping' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' import { DomWidgetState } from '@/stores/domWidgetStore' -import { useCanvasStore } from '@/stores/graphStore' -import { useSettingStore } from '@/stores/settingStore' const { widgetState } = defineProps<{ widgetState: DomWidgetState diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index c91589407..f90922e39 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -143,10 +143,10 @@ import { useI18n } from 'vue-i18n' import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' import { useManagerState } from '@/composables/useManagerState' -import { type ReleaseNote } from '@/services/releaseService' +import { useSettingStore } from '@/platform/settings/settingStore' +import { type ReleaseNote } from '@/platform/updates/common/releaseService' +import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { useCommandStore } from '@/stores/commandStore' -import { useReleaseStore } from '@/stores/releaseStore' -import { useSettingStore } from '@/stores/settingStore' import { ManagerTab } from '@/types/comfyManagerTypes' import { electronAPI, isElectron } from '@/utils/envUtil' import { formatVersionAnchor } from '@/utils/formatUtil' diff --git a/src/components/input/MultiSelect.accessibility.stories.ts b/src/components/input/MultiSelect.accessibility.stories.ts new file mode 100644 index 000000000..5df8fe5a7 --- /dev/null +++ b/src/components/input/MultiSelect.accessibility.stories.ts @@ -0,0 +1,380 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import type { MultiSelectProps } from 'primevue/multiselect' +import { ref } from 'vue' + +import MultiSelect from './MultiSelect.vue' +import { type SelectOption } from './types' + +// Combine our component props with PrimeVue MultiSelect props +interface ExtendedProps extends Partial { + // Our custom props + label?: string + showSearchBox?: boolean + showSelectedCount?: boolean + showClearButton?: boolean + searchPlaceholder?: string + listMaxHeight?: string + popoverMinWidth?: string + popoverMaxWidth?: string + // Override modelValue type to match our Option type + modelValue?: SelectOption[] +} + +const meta: Meta = { + title: 'Components/Input/MultiSelect/Accessibility', + component: MultiSelect, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: ` +# MultiSelect Accessibility Guide + +This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines. + +## Keyboard Navigation + +- **Tab** - Focus the trigger button +- **Enter/Space** - Open/close dropdown when focused +- **Arrow Up/Down** - Navigate through options when dropdown is open +- **Enter/Space** - Select/deselect options when navigating +- **Escape** - Close dropdown + +## Screen Reader Support + +- Uses \`role="combobox"\` to identify as dropdown +- \`aria-haspopup="listbox"\` indicates popup contains list +- \`aria-expanded\` shows dropdown state +- \`aria-label\` provides accessible name with i18n fallback +- Selected count announced to screen readers + +## Testing Instructions + +1. **Tab Navigation**: Use Tab key to focus the component +2. **Keyboard Opening**: Press Enter or Space to open dropdown +3. **Option Navigation**: Use Arrow keys to navigate options +4. **Selection**: Press Enter/Space to select options +5. **Closing**: Press Escape to close dropdown +6. **Screen Reader**: Test with screen reader software + +Try these stories with keyboard-only navigation! + ` + } + } + }, + argTypes: { + label: { + control: 'text', + description: 'Label for the trigger button' + }, + showSearchBox: { + control: 'boolean', + description: 'Show search box in dropdown header' + }, + showSelectedCount: { + control: 'boolean', + description: 'Show selected count in dropdown header' + }, + showClearButton: { + control: 'boolean', + description: 'Show clear all button in dropdown header' + } + } +} + +export default meta +type Story = StoryObj + +const frameworkOptions = [ + { name: 'React', value: 'react' }, + { name: 'Vue', value: 'vue' }, + { name: 'Angular', value: 'angular' }, + { name: 'Svelte', value: 'svelte' }, + { name: 'TypeScript', value: 'typescript' }, + { name: 'JavaScript', value: 'javascript' } +] + +export const KeyboardNavigationDemo: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedFrameworks = ref([]) + const searchQuery = ref('') + + return { + args: { + ...args, + options: frameworkOptions, + modelValue: selectedFrameworks, + 'onUpdate:modelValue': (value: SelectOption[]) => { + selectedFrameworks.value = value + }, + 'onUpdate:searchQuery': (value: string) => { + searchQuery.value = value + } + }, + selectedFrameworks, + searchQuery + } + }, + template: ` +
+
+

๐ŸŽฏ Keyboard Navigation Test

+

+ Use your keyboard to navigate this MultiSelect: +

+
    +
  1. Tab to focus the dropdown
  2. +
  3. Enter/Space to open dropdown
  4. +
  5. Arrow Up/Down to navigate options
  6. +
  7. Enter/Space to select options
  8. +
  9. Escape to close dropdown
  10. +
+
+ +
+ + +

+ Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }} +

+
+
+ ` + }), + args: { + label: 'Choose Frameworks', + showSearchBox: true, + showSelectedCount: true, + showClearButton: true + } +} + +export const ScreenReaderFriendly: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedColors = ref([]) + const selectedSizes = ref([]) + + const colorOptions = [ + { name: 'Red', value: 'red' }, + { name: 'Blue', value: 'blue' }, + { name: 'Green', value: 'green' }, + { name: 'Yellow', value: 'yellow' } + ] + + const sizeOptions = [ + { name: 'Small', value: 'sm' }, + { name: 'Medium', value: 'md' }, + { name: 'Large', value: 'lg' }, + { name: 'Extra Large', value: 'xl' } + ] + + return { + selectedColors, + selectedSizes, + colorOptions, + sizeOptions, + args + } + }, + template: ` +
+
+

โ™ฟ Screen Reader Test

+

+ These dropdowns have proper ARIA attributes and labels for screen readers: +

+
    +
  • role="combobox" identifies as dropdown
  • +
  • aria-haspopup="listbox" indicates popup type
  • +
  • aria-expanded shows open/closed state
  • +
  • aria-label provides accessible name
  • +
  • Selection count announced to assistive technology
  • +
+
+ +
+
+ + +

+ {{ selectedColors.length }} color(s) selected +

+
+ +
+ + +

+ {{ selectedSizes.length }} size(s) selected +

+
+
+
+ ` + }) +} + +export const FocusManagement: Story = { + render: (args) => ({ + components: { MultiSelect }, + setup() { + const selectedItems = ref([]) + const focusTestOptions = [ + { name: 'Option A', value: 'a' }, + { name: 'Option B', value: 'b' }, + { name: 'Option C', value: 'c' } + ] + + return { + selectedItems, + focusTestOptions, + args + } + }, + template: ` +
+
+

๐ŸŽฏ Focus Management Test

+

+ Test focus behavior with multiple form elements: +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Test: Tab through all elements and verify focus rings are visible and logical. +
+
+ ` + }) +} + +export const AccessibilityChecklist: Story = { + render: () => ({ + template: ` +
+
+

โ™ฟ MultiSelect Accessibility Checklist

+ +
+
+

โœ… Implemented Features

+
    +
  • + โœ“ + Keyboard Navigation: Tab, Enter, Space, Arrow keys, Escape +
  • +
  • + โœ“ + ARIA Attributes: role, aria-haspopup, aria-expanded, aria-label +
  • +
  • + โœ“ + Focus Management: Visible focus rings and logical tab order +
  • +
  • + โœ“ + Internationalization: Translatable aria-label fallbacks +
  • +
  • + โœ“ + Screen Reader Support: Proper announcements and state +
  • +
  • + โœ“ + Color Contrast: Meets WCAG AA requirements +
  • +
+
+ +
+

๐Ÿ“‹ Testing Guidelines

+
    +
  1. Keyboard Only: Navigate using only keyboard
  2. +
  3. Screen Reader: Test with NVDA, JAWS, or VoiceOver
  4. +
  5. Focus Visible: Ensure focus rings are always visible
  6. +
  7. Tab Order: Verify logical progression
  8. +
  9. Announcements: Check state changes are announced
  10. +
  11. Escape Behavior: Escape always closes dropdown
  12. +
+
+
+ +
+

๐ŸŽฏ Quick Test

+

+ Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above. + If you can successfully navigate and make selections, the accessibility implementation is working! +

+
+
+
+ ` + }) +} diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts index e4b41d68f..45a1bdbdb 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/input/MultiSelect.stories.ts @@ -3,6 +3,7 @@ import type { MultiSelectProps } from 'primevue/multiselect' import { ref } from 'vue' import MultiSelect from './MultiSelect.vue' +import { type SelectOption } from './types' // Combine our component props with PrimeVue MultiSelect props // Since we use v-bind="$attrs", all PrimeVue props are available @@ -13,8 +14,11 @@ interface ExtendedProps extends Partial { showSelectedCount?: boolean showClearButton?: boolean searchPlaceholder?: string + listMaxHeight?: string + popoverMinWidth?: string + popoverMaxWidth?: string // Override modelValue type to match our Option type - modelValue?: Array<{ name: string; value: string }> + modelValue?: SelectOption[] } const meta: Meta = { @@ -42,6 +46,18 @@ const meta: Meta = { }, searchPlaceholder: { control: 'text' + }, + listMaxHeight: { + control: 'text', + description: 'Maximum height of the dropdown list' + }, + popoverMinWidth: { + control: 'text', + description: 'Minimum width of the popover' + }, + popoverMaxWidth: { + control: 'text', + description: 'Maximum width of the popover' } }, args: { @@ -274,3 +290,140 @@ export const CustomSearchPlaceholder: Story = { searchPlaceholder: 'Filter packages...' } } + +export const CustomMaxHeight: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const selected1 = ref([]) + const selected2 = ref([]) + const selected3 = ref([]) + const manyOptions = Array.from({ length: 20 }, (_, i) => ({ + name: `Option ${i + 1}`, + value: `option${i + 1}` + })) + return { selected1, selected2, selected3, manyOptions } + }, + template: ` +
+
+

Small Height (10rem)

+ +
+
+

Default Height (28rem)

+ +
+
+

Large Height (32rem)

+ +
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + slot: { disable: true } + } +} + +export const CustomMinWidth: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const selected1 = ref([]) + const selected2 = ref([]) + const selected3 = ref([]) + const options = [ + { name: 'A', value: 'a' }, + { name: 'B', value: 'b' }, + { name: 'Very Long Option Name Here', value: 'long' } + ] + return { selected1, selected2, selected3, options } + }, + template: ` +
+
+

Auto Width

+ +
+
+

Min Width 18rem

+ +
+
+

Min Width 28rem

+ +
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + slot: { disable: true } + } +} + +export const CustomMaxWidth: Story = { + render: () => ({ + components: { MultiSelect }, + setup() { + const selected1 = ref([]) + const selected2 = ref([]) + const selected3 = ref([]) + const longOptions = [ + { name: 'Short', value: 'short' }, + { + name: 'This is a very long option name that would normally expand the dropdown', + value: 'long1' + }, + { + name: 'Another extremely long option that demonstrates max-width constraint', + value: 'long2' + } + ] + return { selected1, selected2, selected3, longOptions } + }, + template: ` +
+
+

Auto Width

+ +
+
+

Max Width 18rem

+ +
+
+

Min 12rem Max 22rem

+ +
+
+ ` + }), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + slot: { disable: true } + } +} diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index ffafde707..af9f5075f 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -1,10 +1,9 @@ @@ -15,20 +15,56 @@ import InputText from 'primevue/inputtext' import { computed } from 'vue' -const { placeHolder, showBorder = false } = defineProps<{ +import { cn } from '@/utils/tailwindUtil' + +const { + placeHolder, + showBorder = false, + size = 'md' +} = defineProps<{ placeHolder?: string showBorder?: boolean + size?: 'md' | 'lg' }>() // defineModel without arguments uses 'modelValue' as the prop name const searchQuery = defineModel() const wrapperStyle = computed(() => { - return showBorder - ? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700' - : 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800' + const baseClasses = [ + 'relative flex w-full items-center gap-2', + 'bg-white dark-theme:bg-zinc-800', + 'cursor-text' + ] + + if (showBorder) { + return cn( + ...baseClasses, + 'rounded p-2', + 'border border-solid', + 'border-zinc-200 dark-theme:border-zinc-700' + ) + } + + // Size-specific classes matching button sizes for consistency + const sizeClasses = { + md: 'h-8 px-2 py-1.5', // Matches button sm size + lg: 'h-10 px-4 py-2' // Matches button md size + }[size] + + return cn(...baseClasses, 'rounded-lg', sizeClasses) +}) + +const inputStyle = computed(() => { + return cn( + 'absolute inset-0 w-full h-full pl-11', + 'border-none outline-none bg-transparent', + 'text-sm text-neutral dark-theme:text-white' + ) }) const iconColorStyle = computed(() => { - return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700' + return cn( + !showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700'] + ) }) diff --git a/src/components/input/SingleSelect.accessibility.stories.ts b/src/components/input/SingleSelect.accessibility.stories.ts new file mode 100644 index 000000000..cc58466d1 --- /dev/null +++ b/src/components/input/SingleSelect.accessibility.stories.ts @@ -0,0 +1,464 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import SingleSelect from './SingleSelect.vue' + +interface SingleSelectProps { + label?: string + options?: Array<{ name: string; value: string }> + listMaxHeight?: string + popoverMinWidth?: string + popoverMaxWidth?: string + modelValue?: string | null +} + +const meta: Meta = { + title: 'Components/Input/SingleSelect/Accessibility', + component: SingleSelect, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: ` +# SingleSelect Accessibility Guide + +This SingleSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines. + +## Keyboard Navigation + +- **Tab** - Focus the trigger button +- **Enter/Space** - Open/close dropdown when focused +- **Arrow Up/Down** - Navigate through options when dropdown is open +- **Enter/Space** - Select option when navigating +- **Escape** - Close dropdown + +## Screen Reader Support + +- Uses \`role="combobox"\` to identify as dropdown +- \`aria-haspopup="listbox"\` indicates popup contains list +- \`aria-expanded\` shows dropdown state +- \`aria-label\` provides accessible name with i18n fallback +- Selected option announced to screen readers + +## Testing Instructions + +1. **Tab Navigation**: Use Tab key to focus the component +2. **Keyboard Opening**: Press Enter or Space to open dropdown +3. **Option Navigation**: Use Arrow keys to navigate options +4. **Selection**: Press Enter/Space to select an option +5. **Closing**: Press Escape to close dropdown +6. **Screen Reader**: Test with screen reader software + +Try these stories with keyboard-only navigation! + ` + } + } + }, + argTypes: { + label: { + control: 'text', + description: 'Label for the trigger button' + }, + listMaxHeight: { + control: 'text', + description: 'Maximum height of dropdown list' + } + } +} + +export default meta +type Story = StoryObj + +const sortOptions = [ + { name: 'Name A โ†’ Z', value: 'name-asc' }, + { name: 'Name Z โ†’ A', value: 'name-desc' }, + { name: 'Most Popular', value: 'popular' }, + { name: 'Most Recent', value: 'recent' }, + { name: 'File Size', value: 'size' } +] + +const priorityOptions = [ + { name: 'High Priority', value: 'high' }, + { name: 'Medium Priority', value: 'medium' }, + { name: 'Low Priority', value: 'low' }, + { name: 'No Priority', value: 'none' } +] + +export const KeyboardNavigationDemo: Story = { + render: (args) => ({ + components: { SingleSelect }, + setup() { + const selectedSort = ref(null) + const selectedPriority = ref('medium') + + return { + args, + selectedSort, + selectedPriority, + sortOptions, + priorityOptions + } + }, + template: ` +
+
+

๐ŸŽฏ Keyboard Navigation Test

+

+ Use your keyboard to navigate these SingleSelect dropdowns: +

+
    +
  1. Tab to focus the dropdown
  2. +
  3. Enter/Space to open dropdown
  4. +
  5. Arrow Up/Down to navigate options
  6. +
  7. Enter/Space to select option
  8. +
  9. Escape to close dropdown
  10. +
+
+ +
+
+ + +

+ Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }} +

+
+ +
+ + + + +

+ Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }} +

+
+
+
+ ` + }) +} + +export const ScreenReaderFriendly: Story = { + render: (args) => ({ + components: { SingleSelect }, + setup() { + const selectedLanguage = ref('en') + const selectedTheme = ref(null) + + const languageOptions = [ + { name: 'English', value: 'en' }, + { name: 'Spanish', value: 'es' }, + { name: 'French', value: 'fr' }, + { name: 'German', value: 'de' }, + { name: 'Japanese', value: 'ja' } + ] + + const themeOptions = [ + { name: 'Light Theme', value: 'light' }, + { name: 'Dark Theme', value: 'dark' }, + { name: 'Auto (System)', value: 'auto' }, + { name: 'High Contrast', value: 'contrast' } + ] + + return { + selectedLanguage, + selectedTheme, + languageOptions, + themeOptions, + args + } + }, + template: ` +
+
+

โ™ฟ Screen Reader Test

+

+ These dropdowns have proper ARIA attributes and labels for screen readers: +

+
    +
  • role="combobox" identifies as dropdown
  • +
  • aria-haspopup="listbox" indicates popup type
  • +
  • aria-expanded shows open/closed state
  • +
  • aria-label provides accessible name
  • +
  • Selected option value announced to assistive technology
  • +
+
+ +
+
+ + +

+ Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }} +

+
+ +
+ + +

+ Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }} +

+
+
+ +
+

๐ŸŽง Screen Reader Testing Tips

+
    +
  • โ€ข Listen for role announcements when focusing
  • +
  • โ€ข Verify dropdown state changes are announced
  • +
  • โ€ข Check that selected values are spoken clearly
  • +
  • โ€ข Ensure option navigation is announced
  • +
+
+
+ ` + }) +} + +export const FormIntegration: Story = { + render: (args) => ({ + components: { SingleSelect }, + setup() { + const formData = ref({ + category: null as string | null, + status: 'draft' as string | null, + assignee: null as string | null + }) + + const categoryOptions = [ + { name: 'Bug Report', value: 'bug' }, + { name: 'Feature Request', value: 'feature' }, + { name: 'Documentation', value: 'docs' }, + { name: 'Question', value: 'question' } + ] + + const statusOptions = [ + { name: 'Draft', value: 'draft' }, + { name: 'Review', value: 'review' }, + { name: 'Approved', value: 'approved' }, + { name: 'Published', value: 'published' } + ] + + const assigneeOptions = [ + { name: 'Alice Johnson', value: 'alice' }, + { name: 'Bob Smith', value: 'bob' }, + { name: 'Carol Davis', value: 'carol' }, + { name: 'David Wilson', value: 'david' } + ] + + const handleSubmit = () => { + alert('Form submitted with: ' + JSON.stringify(formData.value, null, 2)) + } + + return { + formData, + categoryOptions, + statusOptions, + assigneeOptions, + handleSubmit, + args + } + }, + template: ` +
+
+

๐Ÿ“ Form Integration Test

+

+ Test keyboard navigation through a complete form with SingleSelect components. + Tab order should be logical and all elements should be accessible. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +