Compare commits
59 Commits
glary/fe-5
...
ext-api/i-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5f3cf550 | ||
|
|
d4b04bac6c | ||
|
|
c5865d3717 | ||
|
|
2ec4fec8c2 | ||
|
|
c614243e36 | ||
|
|
e010c47110 | ||
|
|
192c102c7a | ||
|
|
58d6d2a157 | ||
|
|
e7f642765f | ||
|
|
96addd0e94 | ||
|
|
7200eb0dc4 | ||
|
|
e616a9386a | ||
|
|
fe6d4399c3 | ||
|
|
6dd361bbca | ||
|
|
8f68be5699 | ||
|
|
653ef1a4f0 | ||
|
|
c16052e2e3 | ||
|
|
3e94459340 | ||
|
|
ca54877f9d | ||
|
|
a4faaa0159 | ||
|
|
8108967d49 | ||
|
|
0ef98de8eb | ||
|
|
88866fc564 | ||
|
|
1f4a4af079 | ||
|
|
c8c0e53865 | ||
|
|
c8360a092f | ||
|
|
68843967cf | ||
|
|
8c295e7c68 | ||
|
|
219a574eed | ||
|
|
fef2cab31e | ||
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee | ||
|
|
1ab9752af8 | ||
|
|
e469611f6d | ||
|
|
ad6cbf7cbe | ||
|
|
5ebf5e03ae | ||
|
|
d3ab2be695 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
666684e6e6 |
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,10 +54,14 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
|
||||
#
|
||||
# Runs on any PR touching extension-api declaration files, extension-api-v2
|
||||
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
|
||||
#
|
||||
# Two jobs:
|
||||
# test — vitest run against src/extension-api-v2/__tests__/
|
||||
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
|
||||
# blast_radius ≥ 2.0 category is missing a stub triple)
|
||||
#
|
||||
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
|
||||
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
|
||||
name: 'CI: Tests Extension API'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Extension API tests (vitest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run extension-api test suite
|
||||
run: pnpm test:extension-api
|
||||
|
||||
- name: Run with coverage (push only)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm test:extension-api:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name == 'push'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: extension-api
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
compat-floor:
|
||||
name: Compat-floor gate (blast_radius ≥ 2.0)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Check compat floor
|
||||
run: python3 scripts/check-compat-floor.py
|
||||
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
|
||||
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.
|
||||
97
.github/workflows/extension-api-publish.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
|
||||
#
|
||||
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
|
||||
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
|
||||
#
|
||||
# Prerequisites (one-time human setup):
|
||||
# - NPM_TOKEN secret must be set in the repo/org settings with publish
|
||||
# access to the @comfyorg scope on npmjs.com.
|
||||
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
|
||||
#
|
||||
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Publish'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'extension-api-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run — build and verify without publishing'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create GitHub Release
|
||||
id-token: write # needed for npm provenance via OIDC
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # full history for release notes
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Setup npm registry
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Verify package version matches tag
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
|
||||
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
|
||||
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
|
||||
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version check passed: $PKG_VERSION"
|
||||
|
||||
- name: Publish to npm (with provenance)
|
||||
if: github.event_name == 'push' || inputs.dry_run == 'false'
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Dry-run report
|
||||
if: inputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "=== DRY RUN — would publish ==="
|
||||
cd packages/extension-api
|
||||
npm pack --dry-run
|
||||
echo "=== End dry run ==="
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = context.ref.replace('refs/tags/', '')
|
||||
const { data: release } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: tag,
|
||||
generate_release_notes: true,
|
||||
draft: false,
|
||||
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
|
||||
})
|
||||
console.log(`Release created: ${release.html_url}`)
|
||||
65
.github/workflows/extension-api-typecheck.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Description: Typecheck and build the @comfyorg/extension-api package.
|
||||
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
|
||||
# implementations, or the package scaffold — so regressions in the published
|
||||
# contract are caught before merge.
|
||||
#
|
||||
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Typecheck'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Build + typecheck @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build package (emit declarations)
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
|
||||
# Verifies the published types are consumable from an external module
|
||||
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
|
||||
# checked in to packages/extension-api/test/smoke/.
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
if [ -d test/smoke ]; then
|
||||
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
|
||||
else
|
||||
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
|
||||
fi
|
||||
@@ -172,7 +172,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
|
||||
18. Try to minimize the surface area (exported values) of each module and composable
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`. **Exception**: `src/extension-api/index.ts` is the published npm package entry point (`@comfyorg/extension-api`) and is explicitly exempt from this rule.
|
||||
20. Keep functions short and functional
|
||||
21. Minimize [nesting](https://wiki.c2.com/?ArrowAntiPattern), e.g. `if () { ... }` or `for () { ... }`
|
||||
22. Avoid mutable state, prefer immutability and assignment at point of declaration
|
||||
|
||||
33
apps/website/e2e/customers.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -22,6 +23,10 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +42,10 @@ useHeroAnimation({
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
@@ -1119,6 +1119,10 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1143,6 +1147,10 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
|
||||
1449
browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
Normal file
@@ -190,6 +190,9 @@ export class ComfyPage {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
|
||||
public isVueNodes = false
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
@@ -352,6 +355,12 @@ export class ComfyPage {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async idleFrames(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number) {
|
||||
return sleep(ms)
|
||||
}
|
||||
@@ -494,6 +503,7 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
comfyPage.isVueNodes = isVueNodes
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
|
||||
@@ -217,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -39,10 +39,32 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ContextMenu {
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Topbar {
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
return this.page.locator('.p-dialog-content input')
|
||||
return this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
saveWorkflow(workflowName: string): Promise<void> {
|
||||
@@ -116,9 +116,9 @@ export class Topbar {
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const confirmationDialog = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
12
browser_tests/fixtures/components/WidgetSelectDropdown.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
|
||||
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly mobile: MobileAppHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
@@ -60,13 +62,16 @@ export class AppModeHelper {
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
/** The main content area where outputs are displayed*/
|
||||
public readonly centerPanel: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.mobile = new MobileAppHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
@@ -125,6 +130,7 @@ export class AppModeHelper {
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
33
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class MobileAppHelper {
|
||||
private readonly page: Page
|
||||
readonly contentPanel: Locator
|
||||
readonly navigation: Locator
|
||||
readonly navigationTabs: Locator
|
||||
readonly view: Locator
|
||||
readonly workflows: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.page = comfyPage.page
|
||||
this.view = this.page.getByTestId(TestIds.linear.mobile)
|
||||
this.contentPanel = this.page.getByRole('tabpanel')
|
||||
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
|
||||
this.navigationTabs = this.navigation.getByRole('tab')
|
||||
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
|
||||
}
|
||||
|
||||
async switchWorkflow(workflowName: string) {
|
||||
await this.workflows.click()
|
||||
await this.page.getByRole('menu').getByText(workflowName).click()
|
||||
}
|
||||
async navigateTab(name: 'run' | 'outputs' | 'assets') {
|
||||
await this.navigation.getByRole('tab', { name }).click()
|
||||
}
|
||||
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
|
||||
for (let i = 0; i < count; i++) await locator.tap()
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -362,6 +362,9 @@ export class SubgraphHelper {
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
if (this.comfyPage.isVueNodes) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
|
||||
@@ -144,6 +144,14 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export class VueNodeFixture {
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -23,6 +24,7 @@ export class VueNodeFixture {
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -39,6 +41,16 @@ export class VueNodeFixture {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select this node and delete it via the Delete key, waiting for the node
|
||||
* element to leave the DOM before resolving.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.header.click()
|
||||
await this.header.press('Delete')
|
||||
await this.locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
154
browser_tests/tests/appMode.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
||||
|
||||
//an app without an image input will load the workflow
|
||||
await test.step('App without an image input loads workflow', async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
})
|
||||
|
||||
const imageInput = new WidgetSelectDropdownFixture(
|
||||
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
|
||||
)
|
||||
|
||||
await test.step('Enter app mode with image input', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging an image redirects to image input', async () => {
|
||||
const initialImage = await imageInput.selectedItem()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropExternalResource({
|
||||
fileName: 'workflow.webp',
|
||||
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
|
||||
|
||||
await expect(imageInput.selection).not.toHaveText(initialImage)
|
||||
await expect(
|
||||
centerPanel,
|
||||
'A file with workflow should not open a new workflow'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging a url redirects to image input', async () => {
|
||||
const secondImage = await imageInput.selectedItem()
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: 'og-image.png',
|
||||
type: 'input'
|
||||
})
|
||||
await expect(imageInput.selection).not.toHaveText(secondImage)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widget Interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
['3', 'seed'],
|
||||
['3', 'sampler_name'],
|
||||
['6', 'text']
|
||||
])
|
||||
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
const { input, incrementButton, decrementButton } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seed)
|
||||
const initialValue = Number(await input.inputValue())
|
||||
|
||||
await seed.dragTo(incrementButton, { steps: 5 })
|
||||
const intermediateValue = Number(await input.inputValue())
|
||||
expect(intermediateValue).toBeGreaterThan(initialValue)
|
||||
|
||||
await seed.dragTo(decrementButton, { steps: 5 })
|
||||
const endValue = Number(await input.inputValue())
|
||||
expect(endValue).toBeLessThan(intermediateValue)
|
||||
|
||||
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
||||
exact: true
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
||||
await expect(mobile.view).toBeVisible()
|
||||
await expect(mobile.navigation).toBeVisible()
|
||||
|
||||
await mobile.navigateTab('assets')
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
||||
|
||||
const buttons = await mobile.navigationTabs.all()
|
||||
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
||||
|
||||
await mobile.navigateTab('run')
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
||||
|
||||
const steps = comfyPage.page.getByRole('spinbutton')
|
||||
const initialValue = Number(await steps.inputValue())
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'increment' }),
|
||||
{ count: 5 }
|
||||
)
|
||||
await expect(steps).toHaveValue(String(initialValue + 5))
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
||||
{ count: 3 }
|
||||
)
|
||||
|
||||
await expect(steps).toHaveValue(String(initialValue + 2))
|
||||
})
|
||||
|
||||
test('workflow selection', async ({ comfyPage }) => {
|
||||
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
||||
for (const name of widgetNames)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
||||
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
||||
|
||||
const widgets = comfyPage.appMode.linearWidgets
|
||||
await comfyPage.appMode.mobile.navigateTab('run')
|
||||
for (let i = 0; i < widgetNames.length; i++) {
|
||||
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
||||
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
121
browser_tests/tests/appModeBuilder.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode builder selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Can independently select inputs of same name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
|
||||
await comfyPage.vueNodes.selectNodes(['6', '7'])
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const prompts = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.locator('.lg-node-widget')
|
||||
const count = await prompts.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(prompts.nth(i)).toBeVisible()
|
||||
await prompts.nth(i).click()
|
||||
await expect(items).toHaveCount(i + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Can select outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToOutputs()
|
||||
|
||||
await comfyPage.nodeOps
|
||||
.getNodeRefById('9')
|
||||
.then((ref) => ref.centerOnNode())
|
||||
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
|
||||
await saveImage.click()
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await expect(items).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
|
||||
//Manually set error state on checkpoint loader
|
||||
//Shouldn't be needed on ci, but has spotty reliability
|
||||
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget(
|
||||
'Load Checkpoint',
|
||||
'ckpt_name'
|
||||
)
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
|
||||
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
|
||||
|
||||
await expect(items).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is initially editable'
|
||||
).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await ksampler.header.dblclick({ force: true })
|
||||
await expect(
|
||||
ksampler.titleEditor.input,
|
||||
'Double clicking node titles will not initiate a rename'
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
// The Save As dialog should appear
|
||||
const saveDialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(saveDialog).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const overwriteDialog = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
|
||||
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'subgraph transition (enter and exit)',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }, testInfo) => {
|
||||
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
|
||||
// The signal is dominated by N=80 mount cost, so a single sample per
|
||||
// CI invocation is sufficient — early-return on subsequent repeats.
|
||||
if (testInfo.repeatEachIndex > 0) return
|
||||
|
||||
// Load workflow with a subgraph containing 80 interior nodes.
|
||||
// Entering the subgraph unmounts root nodes and mounts all 80 interior
|
||||
// nodes synchronously — this is the bottleneck we're measuring.
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
|
||||
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
// Exit back to root graph before measuring a fresh enter/exit cycle
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.idleFrames(10)
|
||||
|
||||
// Start measuring the enter transition
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await comfyPage.vueNodes.waitForNodes(80)
|
||||
await comfyPage.idleFrames(30)
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('workflow execution', async ({ comfyPage }) => {
|
||||
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
42
browser_tests/tests/previewAsText.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
// Simulate a previous execution: backend returned text and the frontend
|
||||
// populated the preview widget values. The next prompt submission must
|
||||
// NOT echo those values back as inputs (which would change the cache
|
||||
// signature and trigger a redundant re-execution).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.name?.startsWith('preview_')) {
|
||||
widget.value = 'rendered preview content from previous execution'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const previewEntry = Object.values(apiWorkflow).find(
|
||||
(n) => n.class_type === 'PreviewAny'
|
||||
)
|
||||
expect(previewEntry).toBeDefined()
|
||||
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
})
|
||||
@@ -3,62 +3,42 @@ import { expect } from '@playwright/test'
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.runButton).toBeVisible()
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const items = menu.getByRole('menuitem')
|
||||
await expect(items).toHaveCount(3)
|
||||
await expect(items.nth(0)).toHaveText('Run')
|
||||
await expect(items.nth(1)).toHaveText('Run (On Change)')
|
||||
await expect(items.nth(2)).toHaveText('Run (Instant)')
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
// Select "Run (On Change)" — a non-default mode so we observe a real change
|
||||
const onChangeItem = menu.getByRole('menuitem').nth(1)
|
||||
await onChangeItem.click()
|
||||
|
||||
await expect(comfyPage.runButton).toContainText('Run (On Change)')
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
@@ -76,7 +56,7 @@ test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
|
||||
@@ -558,5 +558,52 @@ test.describe(
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.fail(
|
||||
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.first()
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const subgraphNodeId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('text')
|
||||
await expect(
|
||||
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await interiorClip.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNodeAfter =
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.describe('Closing a modified workflow tab (FE-419)', () => {
|
||||
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
|
||||
await page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
await expect(
|
||||
activeTab.getByTestId('workflow-dirty-indicator')
|
||||
).toHaveCount(1)
|
||||
}
|
||||
|
||||
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Close anyway' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('clicking "Close anyway" closes the tab without saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Close anyway' })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('dismissing the dialog keeps the modified tab open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
|
||||
328
docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 9. Subgraph promoted widgets use linked inputs
|
||||
|
||||
Date: 2026-05-05
|
||||
|
||||
Appendices:
|
||||
|
||||
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
|
||||
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
|
||||
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Subgraph widget promotion historically had two overlapping representations:
|
||||
|
||||
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
|
||||
2. linked subgraph inputs, where an interior widget-bearing input is exposed
|
||||
through the subgraph boundary.
|
||||
|
||||
This created ambiguous ownership. Runtime value reads could collapse to an
|
||||
interior source widget, while host `widgets_values` could also carry an
|
||||
exterior value. Multiple host instances of the same subgraph could therefore
|
||||
stomp one another, and serialization could mutate interior widgets as a
|
||||
persistence carrier for exterior values.
|
||||
|
||||
The ECS widget migration makes that ambiguity more expensive: widgets are
|
||||
becoming entities with component state keyed by stable entity identity, and
|
||||
subgraphs are modeled as graph boundary structure rather than a separate
|
||||
promotion-specific entity kind.
|
||||
|
||||
## Decision
|
||||
|
||||
Promoted widgets are represented only as standard linked `SubgraphInput`
|
||||
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
|
||||
input on a host `SubgraphNode`. The interior source widget supplies schema,
|
||||
type, options, tooltip, and default metadata, but it is not the owner of the
|
||||
host value.
|
||||
|
||||
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
|
||||
promoted widget. It is a separate preview-exposure system because it has no
|
||||
host-owned widget value, does not feed prompt serialization, and often points at
|
||||
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
|
||||
|
||||
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
|
||||
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
|
||||
those entries. The standard serialized representation is the existing subgraph
|
||||
interface/input form plus host-node `widgets_values`.
|
||||
|
||||
Display-only preview exposures use their own host-node-scoped serialized entry,
|
||||
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
|
||||
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
|
||||
language, not widget language:
|
||||
|
||||
```ts
|
||||
type PreviewExposure = {
|
||||
name: string
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
```
|
||||
|
||||
Host-node scope preserves current behavior where different instances of the
|
||||
same subgraph can choose different exposed previews.
|
||||
|
||||
The entry intentionally stores only host preview identity and source locator
|
||||
identity. `name` is the host-scoped stable identity for this preview exposure,
|
||||
analogous to `SubgraphInput.name`; it is not a display label. It is generated
|
||||
with existing collision behavior, such as `nextUniqueName(...)`, when an
|
||||
exposure is created. Media type, display labels, titles, image/video/audio URLs,
|
||||
and other runtime preview details are derived from the current graph and output
|
||||
state. Array order is the canonical display order. Preview exposures do not get
|
||||
a separate persisted `label` in this slice; if a future rename UX needs one, it
|
||||
should follow the same rule as subgraph inputs: `name` is identity and `label`
|
||||
is display-only.
|
||||
|
||||
Preview exposures are persisted user choices after creation. Packing nodes into
|
||||
a subgraph may auto-add recommended preview exposures for supported output
|
||||
nodes, and users may explicitly add or remove additional preview exposures
|
||||
afterward. Normal load/save does not re-derive previews from node type alone,
|
||||
because that would make old workflows change when support for new preview node
|
||||
types is added. Unresolved preview exposures remain persisted and inert;
|
||||
automatic cleanup does not prune them. They are removed only by explicit user
|
||||
action or by destruction/unpacking of the owning host.
|
||||
|
||||
Preview exposures compose through nested subgraph hosts by chaining immediate
|
||||
boundaries. If an outer subgraph wants to show a preview exposed by an inner
|
||||
subgraph host, the outer `previewExposures` entry points at the immediate inner
|
||||
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
|
||||
identity, not the deepest interior preview name. Runtime preview resolution may
|
||||
then follow the inner host's own preview exposures to find media. Canonical JSON
|
||||
does not persist flattened deep paths, because deep paths would couple host UI
|
||||
state to private nested graph internals.
|
||||
|
||||
## Identity and value ownership
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- Host-scoped identity means the host `SubgraphNode` instance within its
|
||||
containing `graphScope`; the interior source node is not the state or
|
||||
persistence owner.
|
||||
- `SubgraphInput.name` is the stable internal identity.
|
||||
- `SubgraphInput.label` / `localized_name` are display-only.
|
||||
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
|
||||
persisted widget value key.
|
||||
- Source node/widget identity remains metadata for diagnostics, missing-model
|
||||
lookup, schema projection, and migration only.
|
||||
- The host/exterior value wins over the interior/source value during repair,
|
||||
persistence, and prompt serialization.
|
||||
|
||||
This follows the existing widget/slot convention: `name` is identity, `label`
|
||||
is display.
|
||||
|
||||
Promoted-widget value state is a host-scoped sparse overlay over source-widget
|
||||
metadata and defaults. The source widget remains the schema/default provider;
|
||||
host value state is materialized only when the exterior value differs from the
|
||||
effective source default or when restored from persisted host state. Canonical
|
||||
save/load must not eagerly mirror source defaults or use interior widgets as
|
||||
persistence carriers.
|
||||
|
||||
## Forward migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
|
||||
|
||||
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
|
||||
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
|
||||
not quarantined.
|
||||
3. Build a multi-pass association map before mutation:
|
||||
- normalized legacy proxy entry;
|
||||
- projected legacy promoted-widget order;
|
||||
- host `widgets_values` value, preserving sparse holes;
|
||||
- repair strategy or failure reason;
|
||||
- whether the entry is a value widget or display-only preview exposure.
|
||||
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
|
||||
is configured.
|
||||
5. On flush, re-resolve against current graph state, because clone/paste/load
|
||||
flows may have remapped or created nodes and links.
|
||||
6. If already represented by a linked `SubgraphInput`, consider the legacy
|
||||
entry resolved and consume it.
|
||||
7. Otherwise repair through existing subgraph input/link systems.
|
||||
8. If the entry is display-only preview surfacing, migrate it into the separate
|
||||
preview-exposure representation instead of creating a linked `SubgraphInput`.
|
||||
9. If value-widget repair fails, write inert quarantine metadata and warn.
|
||||
|
||||
The repair is idempotent. Pending plans store tuple/value data and re-check the
|
||||
current graph before applying mutations.
|
||||
|
||||
Legacy entries are classified as preview exposures when either:
|
||||
|
||||
- the legacy source name starts with `$$`; or
|
||||
- the source node resolves to a matching pseudo-preview widget, such as a
|
||||
`serialize: false` preview/video/audio UI widget.
|
||||
|
||||
Everything else is treated as a value-widget promotion candidate. An unresolved
|
||||
preview-shaped entry remains inert at runtime and is still persisted, because
|
||||
preview-capable pseudo-widgets and output media can be removed and re-added
|
||||
dynamically. It is not quarantined because it has no user value to preserve. A
|
||||
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
|
||||
failure and follows the quarantine path unless it can resolve to a
|
||||
pseudo-preview widget.
|
||||
|
||||
## Proxy widget error quarantine
|
||||
|
||||
Valid legacy entries that cannot be repaired are persisted in
|
||||
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
|
||||
not hydrate runtime promoted widgets, do not participate in execution, and are
|
||||
not used for app-mode/favorites identity.
|
||||
|
||||
Quarantine entries preserve enough information to avoid data loss and support
|
||||
future tooling:
|
||||
|
||||
```ts
|
||||
type ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: ProxyWidgetTuple
|
||||
reason:
|
||||
| 'missingSourceNode'
|
||||
| 'missingSourceWidget'
|
||||
| 'missingSubgraphInput'
|
||||
| 'ambiguousSubgraphInput'
|
||||
| 'unlinkedSourceWidget'
|
||||
| 'primitiveBypassFailed'
|
||||
hostValue?: TWidgetValue
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
```
|
||||
|
||||
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
|
||||
Workflow-level promotion/value intent is preserved by
|
||||
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
|
||||
|
||||
## Primitive-node repair
|
||||
|
||||
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
|
||||
serve nearly the same purpose as subgraph inputs: they provide a widget value to
|
||||
one or more target widget inputs. The migration repairs this expected legacy
|
||||
shape in the first migration rather than quarantining it by default.
|
||||
|
||||
Primitive repair:
|
||||
|
||||
- coalesces exact duplicate legacy entries during planning;
|
||||
- uses the primitive node's user title as the base input name when the node was
|
||||
renamed, otherwise the primitive output widget name;
|
||||
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
|
||||
- uses the existing primitive merge/config compatibility logic;
|
||||
- creates one `SubgraphInput` for the primitive fanout;
|
||||
- reconnects every former primitive output target to that input in target
|
||||
order, using standard connect/disconnect APIs;
|
||||
- applies the host value when one exists, otherwise seeds from the source
|
||||
primitive value;
|
||||
- leaves the primitive node and its widget value in place, but disconnected and
|
||||
inert.
|
||||
|
||||
Primitive repair is all-or-quarantine. If any target cannot be validated or
|
||||
reconnected, the migration does not leave a partial rewrite; it quarantines the
|
||||
entry with `hostValue` and logs the reason.
|
||||
|
||||
## Serialization
|
||||
|
||||
After repair/quarantine:
|
||||
|
||||
- `properties.proxyWidgets` is omitted for repaired entries;
|
||||
- display-only preview entries are omitted from `properties.proxyWidgets` and
|
||||
emitted through `properties.previewExposures`;
|
||||
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
|
||||
- preview exposures do not carry quarantine values because they do not own user
|
||||
values; unresolved preview exposures remain inert in `previewExposures`;
|
||||
- host `widgets_values` contains host-owned values only for canonical host
|
||||
widgets, not source-owned defaults or interior persistence copies;
|
||||
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
|
||||
- array-form `widgets_values` remains for now.
|
||||
|
||||
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
|
||||
preview rendering, but they do not create prompt inputs, do not create
|
||||
`widgets_values`, do not alter node execution order, do not become executable
|
||||
graph edges, and do not participate in prompt serialization. Runtime mapping
|
||||
from backend `display_node`/output messages to a host preview exposure is a UI
|
||||
projection only.
|
||||
|
||||
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
|
||||
values into connected interior widgets is removed. A temporary TODO should mark
|
||||
that removal point until the migration is proven stable. Host values are
|
||||
serialized through standard subgraph-input widgets instead.
|
||||
|
||||
Longer term, `widgets_values` should move from array order to an object/map
|
||||
keyed by stable widget name, but that migration is out of scope for this
|
||||
decision.
|
||||
|
||||
## App mode, builder, and favorites
|
||||
|
||||
The runtime migration and UI identity migration ship in the same slice. The UI
|
||||
must not persist promoted selections by source node/widget identity after this
|
||||
change.
|
||||
|
||||
Canonical UI identity is:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
Legacy source-identity selections are migrated when they resolve through the
|
||||
standard input created or confirmed by the migration. Unresolved selections are
|
||||
dropped with a warning.
|
||||
|
||||
Preview exposure output selections are also host-scoped and must not persist
|
||||
interior source node identity. Canonical preview/output identity is:
|
||||
|
||||
```ts
|
||||
type PreviewExposureUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
previewName: string
|
||||
}
|
||||
```
|
||||
|
||||
The UI references the explicit preview exposure itself. This keeps subgraphs
|
||||
opaque: consumers select the host boundary contract, not the interior node that
|
||||
currently supplies media. Legacy output selections that refer to interior
|
||||
preview source nodes may migrate if they resolve to a preview-exposure chain;
|
||||
otherwise they are dropped with `console.warn`. There is no separate preview UI
|
||||
quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
### Keep `proxyWidgets` as canonical serialized topology
|
||||
|
||||
Rejected. This preserves two representations for the same concept and keeps
|
||||
source-widget identity in the value-ownership path.
|
||||
|
||||
### Preserve bare promoted widgets as degraded runtime state
|
||||
|
||||
Rejected. This would avoid some migration complexity, but it perpetuates the
|
||||
ambiguity that caused host/source value bugs and makes ECS identity less clear.
|
||||
|
||||
### Quarantine primitive-node promotions by default
|
||||
|
||||
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
|
||||
quarantining them would break users unnecessarily. They are repaired by bypassing
|
||||
the primitive node when the repair can be validated all-or-nothing.
|
||||
|
||||
### Migrate `widgets_values` to object/map form now
|
||||
|
||||
Rejected for this slice. Name-keyed object form is the desired long-term
|
||||
direction, but combining it with the promotion migration increases blast radius
|
||||
for existing workflow consumers that still assume array order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Promoted widget values become host-instance-owned and ECS-compatible.
|
||||
- Source widgets remain metadata/default providers, not persistence carriers.
|
||||
- Legacy workflows are repaired toward one standard representation.
|
||||
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
|
||||
runtime promotion.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Appendix: Before and after flows
|
||||
|
||||
This appendix visualizes the ownership and migration flows described in
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
## Before: proxy widgets and linked inputs overlap
|
||||
|
||||
Historically, promoted widgets could be represented both as serialized
|
||||
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
|
||||
reads could collapse back to the interior source widget, while host
|
||||
`widgets_values` could also carry an exterior value for the same promoted UI.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
|
||||
promotionStore --> sourceWidget[Interior source widget]
|
||||
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
|
||||
sourceWidget --> hostWidget
|
||||
hostValues --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
hostWidget -. may copy value back .-> sourceWidget
|
||||
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
|
||||
|
||||
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
|
||||
class proxyWidgets,promotionStore legacy
|
||||
class sourceWidget,hostValues ambiguous
|
||||
class linkedInput,hostWidget canonical
|
||||
```
|
||||
|
||||
Key problems in the old flow:
|
||||
|
||||
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
|
||||
the same promotion.
|
||||
- Interior source widgets supplied both schema metadata and, in some flows,
|
||||
persisted host values.
|
||||
- Multiple host instances of the same subgraph could stomp one another through
|
||||
the shared interior widget value.
|
||||
- Display-only previews were mixed into widget-promotion language even though
|
||||
they do not own values or feed prompt serialization.
|
||||
|
||||
## After: linked inputs are the promoted-widget boundary
|
||||
|
||||
Promoted value widgets are now represented only as standard linked
|
||||
`SubgraphInput` widgets. The source widget remains the schema/default provider,
|
||||
but the host `SubgraphNode` owns the promoted value.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
subgraphInterface --> subgraphInput[SubgraphInput.name]
|
||||
subgraphInput --> hostWidget[Host-scoped widget entity]
|
||||
hostValues --> hostWidget
|
||||
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
|
||||
schema --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
|
||||
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
|
||||
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
|
||||
sourceWidget -. no host value ownership .-> schema
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
|
||||
class sourceWidget,schema,diagnostics metadata
|
||||
class workflow,hostValues persisted
|
||||
```
|
||||
|
||||
Canonical ownership after the migration:
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- `SubgraphInput.name` is stable identity; labels and localized names are
|
||||
display-only.
|
||||
- Host values win during repair, persistence, and prompt serialization.
|
||||
- Source widgets provide metadata and defaults only.
|
||||
- Canonical saves omit repaired `properties.proxyWidgets` entries.
|
||||
|
||||
## Legacy load migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
|
||||
repair builds a plan before mutating graph state, then re-resolves against the
|
||||
current graph when node IDs and links are stable.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start[Load workflow] --> parse{Parse properties.proxyWidgets}
|
||||
parse -->|invalid raw data| invalid[console.error and ignore]
|
||||
parse -->|valid tuples| plan[Build repair plan]
|
||||
plan --> classify{Classify entry}
|
||||
|
||||
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
|
||||
valueRepair -->|yes| consume[Consume legacy proxy entry]
|
||||
valueRepair -->|no| repair[Repair through subgraph input/link systems]
|
||||
repair --> repairResult{Repair succeeded?}
|
||||
repairResult -->|yes| consume
|
||||
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
|
||||
|
||||
classify -->|primitive fanout| primitive[Validate all primitive targets]
|
||||
primitive --> primitiveResult{All targets reconnectable?}
|
||||
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
|
||||
primitiveRepair --> consume
|
||||
primitiveResult -->|no| quarantine
|
||||
|
||||
classify -->|display-only preview| preview[Create / keep previewExposures entry]
|
||||
preview --> consume
|
||||
|
||||
consume --> save[Canonical save]
|
||||
quarantine --> save
|
||||
save --> omit[Omit repaired entries from proxyWidgets]
|
||||
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
|
||||
save --> keepPreview[Persist previews in previewExposures]
|
||||
|
||||
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef error fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
|
||||
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
|
||||
class quarantine,keepQuarantine warn
|
||||
class invalid error
|
||||
```
|
||||
|
||||
## Preview exposures are separate from value widgets
|
||||
|
||||
Display-only previews, such as `$$canvas-image-preview`, are not promoted
|
||||
widgets. They have host-scoped serialized identity, but they do not create
|
||||
prompt inputs, do not create `widgets_values`, and do not own user values.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
|
||||
previewExposures --> exposure[PreviewExposure.name]
|
||||
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
|
||||
sourceLocator --> runtimePreview[Runtime preview/output state]
|
||||
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
|
||||
|
||||
exposure --> uiIdentity[hostNodeLocator + previewName]
|
||||
runtimePreview -. UI projection only .-> hostCanvas
|
||||
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
|
||||
previewExposures -. no value widget .-> noValue[No widgets_values entry]
|
||||
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
|
||||
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
|
||||
class noPrompt,noValue,noEdge noValue
|
||||
class hostNode persisted
|
||||
```
|
||||
|
||||
For nested subgraphs, preview exposures chain across immediate host boundaries
|
||||
instead of persisting flattened deep paths.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
|
||||
outerExposure --> innerHost[Immediate inner SubgraphNode]
|
||||
innerHost --> innerExposure[Inner previewExposures entry]
|
||||
innerExposure --> deepestPreview[Interior preview source]
|
||||
deepestPreview --> media[Resolved media]
|
||||
|
||||
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
|
||||
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class outerHost,innerHost boundary
|
||||
class outerExposure,innerExposure,deepestPreview,media preview
|
||||
class opaque note
|
||||
```
|
||||
|
||||
## Serialization summary
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
|
||||
canonical --> values[widgets_values for host-owned values]
|
||||
canonical --> previews[properties.previewExposures]
|
||||
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
|
||||
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
|
||||
|
||||
inputs --> valueWidgets[Promoted value widgets]
|
||||
values --> valueWidgets
|
||||
previews --> previewUi[Display-only preview UI]
|
||||
quarantine --> futureTooling[Future recovery tooling]
|
||||
|
||||
valueWidgets --> prompt[Prompt serialization]
|
||||
previewUi -. not serialized into prompt .-> prompt
|
||||
quarantine -. inert .-> prompt
|
||||
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class inputs,values,valueWidgets,prompt,canonical canonical
|
||||
class previews,previewUi,quarantine,futureTooling inert
|
||||
class noProxy removed
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Appendix: Removing `disambiguatingSourceNodeId`
|
||||
|
||||
This appendix explains where the existing promotion system needs
|
||||
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
|
||||
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
removes the pattern from promoted-widget identity.
|
||||
|
||||
## Why the disambiguator exists
|
||||
|
||||
The legacy promotion model identifies a promoted widget by source location:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetSource = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
```
|
||||
|
||||
`sourceNodeId` is the immediate interior node visible from the host subgraph.
|
||||
That is not always the original widget owner. When promotions pass through
|
||||
nested subgraphs, two promoted widgets can have the same immediate
|
||||
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
|
||||
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
|
||||
can choose the right promoted view.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
|
||||
middleNode --> middleWidgetA[Promoted widget view: text]
|
||||
middleNode --> middleWidgetB[Promoted widget view: text]
|
||||
middleWidgetA --> leafA[Leaf source node 17 / widget text]
|
||||
middleWidgetB --> leafB[Leaf source node 42 / widget text]
|
||||
|
||||
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
|
||||
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
|
||||
middleWidgetA -. requires .-> oldKeyA
|
||||
middleWidgetB -. requires .-> oldKeyB
|
||||
|
||||
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
|
||||
|
||||
class outerHost host
|
||||
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
|
||||
class leafA,leafB leaf
|
||||
```
|
||||
|
||||
The disambiguator is therefore not a domain concept. It is compensating for an
|
||||
identity model that asks host UI state to identify private nested internals.
|
||||
|
||||
## Existing places that need it
|
||||
|
||||
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
|
||||
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
|
||||
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
|
||||
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
|
||||
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
|
||||
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
|
||||
|
||||
## New promoted-widget identity
|
||||
|
||||
ADR 0009 moves promoted value identity to the host boundary:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
The canonical widget is owned by a `SubgraphInput` on the host
|
||||
`SubgraphNode`. The host widget no longer needs to identify the deepest source
|
||||
node to preserve value identity. The source widget is consulted for schema,
|
||||
defaults, diagnostics, and migration, but it is not the value owner.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
|
||||
host --> inputB[SubgraphInput.name: negative_prompt]
|
||||
inputA --> hostWidgetA[Host-owned widget entity]
|
||||
inputB --> hostWidgetB[Host-owned widget entity]
|
||||
|
||||
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
|
||||
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
|
||||
|
||||
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
|
||||
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
|
||||
sourceA -. not part of host value key .-> identityA
|
||||
sourceB -. not part of host value key .-> identityB
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
|
||||
class sourceA,sourceB metadata
|
||||
```
|
||||
|
||||
This is the same rule the subgraph interface already uses: `name` is stable
|
||||
identity, and `label` / `localized_name` are display-only.
|
||||
|
||||
## How the new form removes each need
|
||||
|
||||
| Previous disambiguation site | New canonical replacement |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
|
||||
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
|
||||
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
|
||||
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
|
||||
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
|
||||
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
|
||||
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
|
||||
|
||||
## Boundary-by-boundary nested flow
|
||||
|
||||
The new form avoids flattened deep source paths. Each host boundary exposes its
|
||||
own named input, and the next outer host links to that immediate boundary
|
||||
contract.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
|
||||
innerInput --> innerHostWidget[Inner host-owned widget]
|
||||
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
|
||||
outerInput --> outerHostWidget[Outer host-owned widget]
|
||||
|
||||
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
|
||||
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
|
||||
leaf -. schema/default source .-> innerHostWidget
|
||||
leaf -. not persisted as outer value key .-> outerIdentity
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef source fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
|
||||
class leaf source
|
||||
```
|
||||
|
||||
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
|
||||
widgets no longer require a persisted leaf-node disambiguator at the outer host.
|
||||
If the user exposes both, the collision is resolved when the host inputs are
|
||||
created by assigning distinct input names with the existing unique-name
|
||||
behavior.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Appendix: System comparison
|
||||
|
||||
This appendix compares the legacy promoted-widget systems with the canonical
|
||||
linked-input model chosen by
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
|
||||
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
|
||||
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
|
||||
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
|
||||
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
|
||||
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
|
||||
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
|
||||
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
|
||||
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
|
||||
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
|
||||
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
|
||||
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
|
||||
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
|
||||
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
|
||||
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
|
||||
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
|
||||
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
|
||||
|
||||
## Practical migration summary
|
||||
|
||||
| Legacy shape | New result |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
|
||||
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
|
||||
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
|
||||
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
|
||||
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
|
||||
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
|
||||
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
|
||||
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
|
||||
187
docs/architecture/extension-api-v2/names-appendix.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Names That Must Agree Across Layers
|
||||
|
||||
**Task:** DOC1.E6
|
||||
**Date:** 2026-05-08
|
||||
**Patterns cross-walked:** S2.N16 (widget array access), S13.SC1 (ComfyNodeDef inspection), S15.OS1 (dynamic output mutation)
|
||||
|
||||
This appendix enumerates the terms that span at least two of the four layers — Python backend, v1 frontend (`ComfyExtension`/LiteGraph), v2 extension API (`NodeHandle`/`WidgetHandle`), and ECS World components — and calls out real inconsistencies where the same concept is named differently or the semantics diverge. Future contributors who rename or refactor any of these terms must propagate the change across all layers listed.
|
||||
|
||||
---
|
||||
|
||||
## Layers
|
||||
|
||||
| Layer | Owner | Primary source |
|
||||
|-------|-------|---------------|
|
||||
| **Python backend** | ComfyUI server | `NODE_CLASS_MAPPINGS`, `INPUT_TYPES`, `RETURN_TYPES` |
|
||||
| **v1 frontend** | LiteGraph / ComfyExtension | `src/types/comfy.ts`, `src/schemas/nodeDefSchema.ts`, `LGraphNode.ts` |
|
||||
| **v2 extension API** | This project | `src/extension-api/node.ts`, `src/extension-api/widget.ts` |
|
||||
| **ECS World** | Alex's branch (PR #11939) | `src/services/extension-api-service.ts` (stubs), `@/world/entityIds` |
|
||||
|
||||
---
|
||||
|
||||
## Term 1 — Node class identifier (`class_type` / `type` / `comfyClass`)
|
||||
|
||||
**What it is:** The string that identifies which Python class backs a node. Used to look up `object_info`, serialize the prompt, and match `INPUT_TYPES` definitions.
|
||||
|
||||
| Layer | Name | Value example | Notes |
|
||||
|-------|------|--------------|-------|
|
||||
| Python backend | Python class name | `'KSampler'` | The class registered in `NODE_CLASS_MAPPINGS` |
|
||||
| Execution prompt JSON (API format) | `class_type` | `"class_type": "KSampler"` | Key in the flat prompt dict |
|
||||
| UWF backend spec | `class_type` | `"class_type": "KSampler"` | Unchanged from API format (per `uwf-backend-data-model.md`) |
|
||||
| `ComfyNodeDef` (v1 schema) | `name` | `nodeData.name === 'KSampler'` | The `name` field in the server's `/object_info` response |
|
||||
| `LGraphNode` (v1) | `node.type` | `node.type === 'KSampler'` | LiteGraph's class string; set at registration |
|
||||
| v2 `NodeHandle` | `handle.type` | `handle.type === 'KSampler'` | `readonly type: string` in `src/extension-api/node.ts:260` |
|
||||
| v2 `NodeHandle` | `handle.comfyClass` | `handle.comfyClass === 'KSampler'` | `readonly comfyClass: string` in `src/extension-api/node.ts:269` |
|
||||
| ECS `NodeTypeData` component | `type` + `comfyClass` | both fields | Stub in `extension-api-service.ts:61–63` |
|
||||
|
||||
**⚠ Inconsistency — `type` vs `comfyClass`:** v2 `NodeHandle` exposes **both** `type` and `comfyClass` because for most nodes they are equal, but for virtual/reroute nodes `type` is the LiteGraph registration string while `comfyClass` is the actual Python class backing the node. Extensions that compare against ComfyUI node names should always use `comfyClass`. Extensions that filter by LiteGraph registration (for `nodeTypes:` filter in `defineNodeExtension`) should use `type`. The distinction must be preserved — collapsing them back to one field would break reroute/virtual-node detection.
|
||||
|
||||
**Rule:** `class_type` (wire format, Python, UWF) = `comfyClass` (v2 API) = `name` in `ComfyNodeDef` = `node.type` in LiteGraph for non-virtual nodes.
|
||||
|
||||
---
|
||||
|
||||
## Term 2 — Node display name (`display_name` / `title`)
|
||||
|
||||
**What it is:** The human-readable label shown in the node header and search results. Distinct from the class identifier.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `NODE_DISPLAY_NAME_MAPPINGS[class]` (optional) | If absent, falls back to the class name |
|
||||
| `ComfyNodeDef` (v1 schema) | `display_name` | `nodeData.display_name` — always a string per zod schema (`src/schemas/nodeDefSchema.ts:279`) |
|
||||
| `LGraphNode` (v1) | `node.title` | Set during `nodeCreated`; extensions mutate `node.title` directly to rename nodes |
|
||||
| v2 `NodeHandle` | `handle.title` (getter) + `handle.setTitle(s)` | `src/extension-api/node.ts:304–315`; accessor pair per D3.3/D6 hybrid rule |
|
||||
| ECS `NodeVisualData` component | `title` | Field name consistent with LiteGraph |
|
||||
|
||||
**No inconsistency.** `display_name` lives only in the schema/backend layer; `title` is the runtime/frontend term. The two layers don't overlap. Extension authors should use `handle.setTitle()` in v2; `node.title =` is the v1 equivalent. Both are stable.
|
||||
|
||||
---
|
||||
|
||||
## Term 3 — Widget name (`name` / `input_name` / `widget.name`)
|
||||
|
||||
**What it is:** The stable per-node-instance identifier for a widget slot. Used as the key in `widgets_values_named`, `promoted_inputs`, and for `WidgetHandle` lookup.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend `INPUT_TYPES` | Dict key (e.g. `'seed'`, `'steps'`) | The name as declared in the Python class |
|
||||
| UWF `promoted_inputs` | `input_name` | `{ node_id, input_name, display_name }` — snake_case to match Python origin |
|
||||
| UWF `spec.nodes.{id}.inputs` | Named key in the inputs object | e.g. `"seed": { "value": 123, "link": null }` |
|
||||
| v1 `LGraphNode.widgets` | `widget.name` | `widget.name === 'seed'` — used in `node.widgets.find(w => w.name === ...)` (S2.N16 pattern) |
|
||||
| v1 `node.widgets` iteration | position index (implicit) | `widgets_values` positional array — the root cause of widget shift bugs (S17.WV1) |
|
||||
| v2 `NodeHandle.widget(name)` | `name` argument | Lookup by name, not position (`src/extension-api/node.ts:383`) |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `name` parameter | `src/extension-api/node.ts:396–403`; stable key for the widget's lifetime |
|
||||
| v2 `WidgetHandle.name` | `readonly name: string` | `src/extension-api/widget.ts:277` — "stable within the node's lifetime" |
|
||||
| ECS `WidgetComponentSchema` | `name` field (inferred) | Widget schema component expected to carry `name` per `widgetComponents` import |
|
||||
|
||||
**⚠ Inconsistency — positional array vs. named:** v1 serialization stores widget values as a positional array (`widgets_values: [123, 20, 7.5]`). v2 API and UWF both use `name` as the stable key. The bridge is Austin's PR #10392 (`widgets_values_named`). Until that merges, any code that reads `node.widgets[i]` by index is fragile; code that reads `widget.name` is UWF-safe. The v2 `WidgetHandle` **must** be looked up by name, never by index — this is enforced by the API shape.
|
||||
|
||||
**Rule:** Always use the Python-declared input name as the canonical widget identifier. Never use position. `widget.name` (v1) = `name` parameter (v2 addWidget) = `input_name` (UWF wire format).
|
||||
|
||||
---
|
||||
|
||||
## Term 4 — Widget type string (`type` / `widgetType` / widget constructor key)
|
||||
|
||||
**What it is:** The string describing what kind of widget a slot is (e.g. `'INT'`, `'STRING'`, `'COMBO'`, `'IMAGE'`). Controls which widget constructor is used and which validation rules apply.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | Return value of `INPUT_TYPES()` tuple: first element | e.g. `("INT", {"default": 42})` — the type string |
|
||||
| `ComfyNodeDef` / `InputSpec` | Type string as zod-inferred from the schema | `INPUT_TYPES['required']['steps'] = ['INT', {...}]` |
|
||||
| `zBaseInputOptions` | `widgetType` field (optional override) | `src/schemas/nodeDefSchema.ts:33` — overrides the slot type for widget selection; rare |
|
||||
| v1 `getCustomWidgets` return | Record key | `{ MY_WIDGET: constructor }` — extension-registered type strings |
|
||||
| v1 `node.addWidget(type, ...)` | `type` first arg | The LiteGraph widget constructor key |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `type` first arg | `src/extension-api/node.ts:395, 403` |
|
||||
| v2 `WidgetHandle.type` | `readonly type: string` | `src/extension-api/widget.ts` line ~280 |
|
||||
| ECS `WidgetComponentSchema` | `type` field | Expected to match the Python-declared type string |
|
||||
|
||||
**⚠ Inconsistency — `widgetType` override:** The `widgetType` field in `zBaseInputOptions` is an override that makes the frontend render a different widget than the Python type implies. Extensions that inspect `nodeData.input.required[name][1].widgetType` to determine rendering (S13.SC1 pattern) must check this field **before** using the slot's primary type. The v2 `ComfyNodeDef`-inspection helper (`ctx.inspectNodeDef`) must resolve this correctly — it cannot just return `InputSpec[0]` (the Python type) as `widgetType`.
|
||||
|
||||
**Rule:** Python type string = v1 `widget.type` = v2 `WidgetHandle.type` = `widgetType` override if present (takes precedence).
|
||||
|
||||
---
|
||||
|
||||
## Term 5 — Slot type string (connection type / `'IMAGE'`, `'LATENT'`, etc.)
|
||||
|
||||
**What it is:** The type label on a node's input/output slot that governs which connections are valid. Distinct from widget type (a slot may be a pure connection point with no widget).
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_TYPES` tuple element | e.g. `RETURN_TYPES = ('IMAGE', 'MASK')` |
|
||||
| Python backend `INPUT_TYPES` | First element of required/optional tuple | `'IMAGE'` means "must receive an IMAGE connection" |
|
||||
| UWF `spec.nodes.{id}.outputs` | `{ "name": "IMAGE" }` per output | Output type declarations (new in UWF — not in old API format) |
|
||||
| v1 `LGraphNode` slot | `slot.type` | String on the slot object; extensions read/mutate via S15.OS1 |
|
||||
| v1 `node.addInput(name, type)` | `type` second arg | e.g. `node.addInput('mask', 'MASK')` |
|
||||
| v1 `node.addOutput(name, type)` | `type` second arg | S15.OS1 pattern |
|
||||
| v2 `SlotInfo` | `readonly type: string` | `src/extension-api/node.ts:82–83` |
|
||||
| v2 slot events | `event.slot.type` | Available in `onSlotConnected`, `onSlotDisconnected` |
|
||||
|
||||
**⚠ Inconsistency — dynamic mutation (S15.OS1):** v1 allows `slot.type = 'IMAGE'` and `node.outputs[i].type = newType` at runtime. v2 restricts this: output types must be declared in `INPUT_TYPES` / schema; runtime mutation is only via `node.declareOutputs(spec)` (proposed, not yet implemented). This is an intentional breaking change. The UWF spec formalizes this by requiring `spec.nodes.{id}.outputs` to be declared at save time, not derived from runtime state.
|
||||
|
||||
**Rule:** Slot type strings are uppercase by convention (matching Python `RETURN_TYPES`). v2 enforces schema-declaration; mutation-at-runtime is deprecated.
|
||||
|
||||
---
|
||||
|
||||
## Term 6 — Node output name (`output_name` / `RETURN_NAMES`)
|
||||
|
||||
**What it is:** Optional human-readable names for a node's output slots. Not the type — the label shown on the output connector.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_NAMES` class attribute | e.g. `RETURN_NAMES = ('upscaled_image', 'mask')` — optional |
|
||||
| `ComfyNodeDef` schema | `output_name` | `z.array(z.string()).optional()` in `src/schemas/nodeDefSchema.ts:275` |
|
||||
| v1 `LGraphNode` | `output.name` | String on the slot object; `node.outputs[i].name` |
|
||||
| v2 `SlotInfo` | `readonly name: string` | `src/extension-api/node.ts:80–81` — same field name |
|
||||
|
||||
**No inconsistency.** `RETURN_NAMES` → `output_name` in the schema → `slot.name` at runtime. All three refer to the same string. Field name shifts from snake_case (`output_name`) in the schema to camelCase-neutral (`name`) on the slot object — consistent with the rest of the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Term 7 — Extension name (`ComfyExtension.name` / `ExtensionOptions.name`)
|
||||
|
||||
**What it is:** The unique identifier for an extension used for hook ordering (D10b), scope registry keys, deprecation telemetry, and conflict detection.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| v1 `ComfyExtension` | `name: string` | Required field; `src/types/comfy.ts:108`; typically a dotted namespace like `'Comfy.Sidebar'` |
|
||||
| v2 `ExtensionOptions` | `name: string` | Required field; `src/extension-api/lifecycle.ts`; same semantic |
|
||||
| ECS scope registry | `extensionName` | Key component of `NodeInstanceScope`; used in `${extensionName}:${nodeEntityId}` scope key |
|
||||
| D6 telemetry | `apiVersion` | Separate field added by I-EXT.3 to `ExtensionOptions` for version tracking — not a replacement for `name` |
|
||||
|
||||
**No inconsistency.** Same field name and semantics across v1 and v2. The scope registry key format is `${extensionName}:${nodeEntityId}` — both components must be stable.
|
||||
|
||||
**Rule:** Extension names should follow the dotted-namespace convention (e.g. `'MyPublisher.MyExtension'`) to avoid collisions. This is currently advisory, not enforced.
|
||||
|
||||
---
|
||||
|
||||
## Term 8 — Node input display name (`display_name` / widget label)
|
||||
|
||||
**What it is:** The label shown next to the widget in the UI. Distinct from the internal `name` (key) used for serialization.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `display_name` in input options dict | `INPUT_TYPES()['required']['steps'] = ['INT', {'display_name': 'Steps'}]` |
|
||||
| `zBaseInputOptions` schema | `display_name: z.string().optional()` | `src/schemas/nodeDefSchema.ts:27` |
|
||||
| UWF `promoted_inputs` | `display_name` field | `{ node_id, input_name, display_name }` — the UI label for app-mode promoted inputs |
|
||||
| v1 `widget` | `widget.label` | Optional; falls back to `widget.name` if absent. Inspected in S2.N16 patterns |
|
||||
| v2 `WidgetHandle` | `label` getter | `src/extension-api/widget.ts:355` — "Defaults to the widget name" |
|
||||
|
||||
**No inconsistency** in naming — all layers call it `display_name` (schema/wire) or `label` (runtime). The two forms are consistent: `display_name` is the static schema-declared label; `label` is the runtime-settable display string. v2 exposes `label` as a settable accessor; the Python-declared `display_name` becomes its initial value.
|
||||
|
||||
---
|
||||
|
||||
## Summary: real inconsistencies to track
|
||||
|
||||
| # | Inconsistency | Risk | Resolution |
|
||||
|---|--------------|------|-----------|
|
||||
| 1 | `type` vs `comfyClass` on `NodeHandle` — two fields, must not collapse | Medium | Document: use `comfyClass` for Python identity, `type` for LiteGraph registration. Enforced by distinct fields. |
|
||||
| 2 | Widget identity: positional index (v1 `widgets_values`) vs name key (v2 / UWF) | **HIGH** | Bridge: Austin's PR #10392 (`widgets_values_named`). v2 `WidgetHandle` is name-only. Never look up by position in v2 code. |
|
||||
| 3 | `widgetType` override in `InputSpec` takes precedence over slot type for rendering | Medium | `ctx.inspectNodeDef` must resolve `widgetType` before returning slot type. Do not skip this field. |
|
||||
| 4 | Slot type mutation (S15.OS1): `slot.type = X` is valid v1, banned in v2/UWF | Medium | v2 must not expose a `setType()` mutator on `SlotInfo`. Schema-declare outputs; UWF enforces at save time. |
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **S2.N16** — widget array iteration/mutation (`node.widgets[i]`, `node.widgets.find(w => w.name === ...)`): the `name` field is the stable key; position is not. v2 forces name-based lookup.
|
||||
- **S13.SC1** — `ComfyNodeDef` inspection: callers must resolve `widgetType` override (Term 4) and understand `display_name` vs runtime `label` (Term 8). The `ctx.inspectNodeDef` typed helper (D4 G1 BLOCKER) wraps this correctly.
|
||||
- **S15.OS1** — dynamic output mutation: slot `type` strings are the agreed layer (Term 5), but v1 allows mutation that v2/UWF forbids. Track in I-PG.B2 as `strangler-bridge` until UWF Phase 3 covers output schema declaration.
|
||||
- **UWF backend data model** — `class_type`, `input_name`, `display_name` snake_case keys mirror Python origin. v2 API uses camelCase (`comfyClass`, widget `name`, widget `label`) per JS convention. No semantic difference; only case convention changes at the API boundary.
|
||||
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
source node/widget identity is migration and diagnostic metadata only.
|
||||
|
||||
## 2. WidgetValueStore
|
||||
|
||||
**File:** `src/stores/widgetValueStore.ts`
|
||||
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
|
||||
instance-specific state beyond inputs — must remain reachable. This is a
|
||||
constraint, not a current requirement.
|
||||
|
||||
### Recommendation and decision criteria
|
||||
### Decision
|
||||
|
||||
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
|
||||
truth: promotion is adding a typed input to a function signature. The type
|
||||
system already handles widget creation for typed inputs. Building a parallel
|
||||
mechanism for "promoted widgets" is building a second, narrower version of
|
||||
something the system already does.
|
||||
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
chooses Candidate A for promoted value widgets. It eliminates an entire
|
||||
subsystem by recognizing a structural truth: promotion is adding a typed input
|
||||
to a function signature. The type system already handles widget creation for
|
||||
typed inputs. Building a parallel mechanism for "promoted widgets" is building
|
||||
a second, narrower version of something the system already does.
|
||||
|
||||
The cost of A is a migration path for existing `proxyWidgets` serialization. On
|
||||
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
|
||||
inputs and boundary links. This is a one-time ratchet conversion — once
|
||||
loaded and re-saved, the workflow uses the new format.
|
||||
|
||||
**Choose B if** the team determines that promoted widgets must remain
|
||||
visually or behaviorally distinct from normal input widgets in ways the type →
|
||||
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
|
||||
the current release cycle's capacity.
|
||||
|
||||
**Decision needed before** Phase 3 of the ECS migration, when systems are
|
||||
introduced and the widget/connectivity architecture solidifies.
|
||||
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
|
||||
into interface inputs and boundary links. Once loaded and re-saved, the workflow
|
||||
uses the new format. ADR 0009 separates display-only preview exposures from
|
||||
promoted value widgets; those previews use their own host-scoped serialized
|
||||
representation instead of linked `SubgraphInput` widgets.
|
||||
|
||||
---
|
||||
|
||||
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ---------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
|
||||
The "ratchet conversion" pattern: load any supported format, normalize to the
|
||||
internal model. The system accepts old formats indefinitely but produces the
|
||||
current format on save.
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
format on save.
|
||||
|
||||
### Widget identity at the boundary
|
||||
|
||||
@@ -511,13 +506,12 @@ SubgraphIO {
|
||||
}
|
||||
```
|
||||
|
||||
If Candidate A (connections-only promotion) is chosen: promoted widgets become
|
||||
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
|
||||
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
|
||||
migration). On save, `proxyWidgets` is no longer written.
|
||||
|
||||
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
|
||||
serialized in its current format.
|
||||
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
|
||||
widgets: they become interface inputs, serialized as additional `SubgraphIO`
|
||||
entries. On load, legacy value-widget `proxyWidgets` data is converted to
|
||||
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
|
||||
are no longer written. Display-only preview exposures use separate
|
||||
host-scoped `previewExposures` serialization.
|
||||
|
||||
### Backward-compatible loading contract
|
||||
|
||||
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
|
||||
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.18",
|
||||
"version": "1.45.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
@@ -47,6 +47,9 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
3
packages/extension-api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
docs-build/
|
||||
build/
|
||||
node_modules/
|
||||
9
packages/extension-api/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
src/
|
||||
scripts/
|
||||
tsconfig*.json
|
||||
typedoc.json
|
||||
docs-build/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
__tests__/
|
||||
node_modules/
|
||||
50
packages/extension-api/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @comfyorg/extension-api
|
||||
|
||||
> **Status**: scaffolded. Package implementation pending PKG3 — see
|
||||
> `../../../plans/P2-extension-api-package.md` and
|
||||
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
|
||||
|
||||
The official TypeScript declaration package for ComfyUI extensions. This
|
||||
package replaces the practice of vendoring `comfy.d.ts` files in custom
|
||||
node repos.
|
||||
|
||||
## Install (post-publish)
|
||||
|
||||
```bash
|
||||
pnpm add -D @comfyorg/extension-api
|
||||
```
|
||||
|
||||
```ts
|
||||
import { defineExtension } from '@comfyorg/extension-api'
|
||||
|
||||
export default defineExtension({
|
||||
name: 'MyExtension',
|
||||
setup(ctx) {
|
||||
ctx.onNodeMounted((node) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This package is built from the source-of-truth folder
|
||||
`../../src/extension-api/`. Do not edit the package's `build/` output
|
||||
directly.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
|
||||
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
|
||||
surface has stabilized.
|
||||
- Breaking changes follow semver strictly from `1.0.0` onward.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
|
||||
- `plans/P2-extension-api-package.md` — package structure plan
|
||||
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
|
||||
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
|
||||
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
|
||||
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration
|
||||
28
packages/extension-api/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@comfyorg/extension-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Official TypeScript extension API for ComfyUI custom nodes",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./build/index.js"
|
||||
},
|
||||
"types": "./build/index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --emitDeclarationOnly --outDir build",
|
||||
"docs:build": "tsx scripts/build-docs.ts",
|
||||
"docs:watch": "tsx scripts/build-docs.ts --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typedoc": "0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.6.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:api"
|
||||
]
|
||||
}
|
||||
}
|
||||
461
packages/extension-api/scripts/build-docs.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
|
||||
*
|
||||
* Steps:
|
||||
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
|
||||
* 2. Post-process each markdown file:
|
||||
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
|
||||
* - Convert ``` fences without lang tag → ```ts
|
||||
* - Replace raw [TypeName] cross-refs with MDX relative links
|
||||
* - Wrap @example blocks in proper code fences
|
||||
* 3. Write final .mdx files to docs-build/mintlify/
|
||||
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
|
||||
*
|
||||
* Run: pnpm --filter @comfyorg/extension-api docs:build
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = path.resolve(__dirname, '..')
|
||||
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
|
||||
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
|
||||
const watchMode = process.argv.includes('--watch')
|
||||
|
||||
// ── Page metadata ────────────────────────────────────────────────────────────
|
||||
// Controls frontmatter for each generated page. Key = TypeDoc output filename
|
||||
// stem (lowercased). Unrecognised files get generic metadata.
|
||||
|
||||
interface PageMeta {
|
||||
title: string
|
||||
sidebarTitle?: string
|
||||
description: string
|
||||
icon?: string
|
||||
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
|
||||
order: number
|
||||
}
|
||||
|
||||
const PAGE_META: Record<string, PageMeta> = {
|
||||
// Top-level overview
|
||||
index: {
|
||||
title: 'Extension API Overview',
|
||||
description: 'TypeScript API reference for ComfyUI custom node extensions.',
|
||||
icon: 'puzzle-piece',
|
||||
group: 'root',
|
||||
order: 0
|
||||
},
|
||||
// Lifecycle / registration
|
||||
defineextension: {
|
||||
title: 'defineExtension',
|
||||
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 1
|
||||
},
|
||||
definenodeextension: {
|
||||
title: 'defineNodeExtension',
|
||||
description: 'Register a node-scoped extension reacting to node lifecycle events.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 2
|
||||
},
|
||||
definewidgetextension: {
|
||||
title: 'defineWidgetExtension',
|
||||
description: 'Register a custom widget type with its own DOM rendering.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 3
|
||||
},
|
||||
extensionoptions: {
|
||||
title: 'ExtensionOptions',
|
||||
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
|
||||
group: 'core',
|
||||
order: 4
|
||||
},
|
||||
nodeextensionoptions: {
|
||||
title: 'NodeExtensionOptions',
|
||||
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
|
||||
group: 'core',
|
||||
order: 5
|
||||
},
|
||||
widgetextensionoptions: {
|
||||
title: 'WidgetExtensionOptions',
|
||||
description: 'Options object for defineWidgetExtension — custom widget rendering.',
|
||||
group: 'core',
|
||||
order: 6
|
||||
},
|
||||
onnoderemoved: {
|
||||
title: 'onNodeRemoved',
|
||||
sidebarTitle: 'onNodeRemoved',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
|
||||
group: 'core',
|
||||
order: 7
|
||||
},
|
||||
onnodemounted: {
|
||||
title: 'onNodeMounted',
|
||||
sidebarTitle: 'onNodeMounted',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
|
||||
group: 'core',
|
||||
order: 8
|
||||
},
|
||||
// Handles
|
||||
nodehandle: {
|
||||
title: 'NodeHandle',
|
||||
description: 'Controlled access to node state, mutations, slots, and events.',
|
||||
icon: 'circle-nodes',
|
||||
group: 'handles',
|
||||
order: 10
|
||||
},
|
||||
widgethandle: {
|
||||
title: 'WidgetHandle',
|
||||
description: 'Controlled access to widget state, mutations, and events.',
|
||||
icon: 'sliders',
|
||||
group: 'handles',
|
||||
order: 11
|
||||
},
|
||||
slotinfo: {
|
||||
title: 'SlotInfo',
|
||||
description: 'Read-only snapshot of a node slot (input or output).',
|
||||
group: 'handles',
|
||||
order: 12
|
||||
},
|
||||
// Events
|
||||
nodeexecutedevent: {
|
||||
title: 'NodeExecutedEvent',
|
||||
description: 'Payload fired when a node finishes execution.',
|
||||
group: 'events',
|
||||
order: 20
|
||||
},
|
||||
nodeconnectedevent: {
|
||||
title: 'NodeConnectedEvent',
|
||||
description: 'Payload fired when a slot connection is made.',
|
||||
group: 'events',
|
||||
order: 21
|
||||
},
|
||||
nodedisconnectedevent: {
|
||||
title: 'NodeDisconnectedEvent',
|
||||
description: 'Payload fired when a slot connection is removed.',
|
||||
group: 'events',
|
||||
order: 22
|
||||
},
|
||||
nodepositionchangedevent: {
|
||||
title: 'NodePositionChangedEvent',
|
||||
description: 'Payload fired when a node is moved on the canvas.',
|
||||
group: 'events',
|
||||
order: 23
|
||||
},
|
||||
nodesizechangedevent: {
|
||||
title: 'NodeSizeChangedEvent',
|
||||
description: 'Payload fired when a node is resized.',
|
||||
group: 'events',
|
||||
order: 24
|
||||
},
|
||||
nodemodechangedevent: {
|
||||
title: 'NodeModeChangedEvent',
|
||||
description: 'Payload fired when a node execution mode changes.',
|
||||
group: 'events',
|
||||
order: 25
|
||||
},
|
||||
nodebeforeserializeevent: {
|
||||
title: 'NodeBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip node data.',
|
||||
group: 'events',
|
||||
order: 26
|
||||
},
|
||||
widgetvaluechangeevent: {
|
||||
title: 'WidgetValueChangeEvent',
|
||||
description: 'Payload fired when a widget value changes.',
|
||||
group: 'events',
|
||||
order: 27
|
||||
},
|
||||
widgetbeforeserializeevent: {
|
||||
title: 'WidgetBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip widget value.',
|
||||
group: 'events',
|
||||
order: 28
|
||||
},
|
||||
widgetbeforequeueevent: {
|
||||
title: 'WidgetBeforeQueueEvent',
|
||||
description: 'Pre-queue validation payload — call reject() to cancel queue.',
|
||||
group: 'events',
|
||||
order: 29
|
||||
},
|
||||
// Shell UI
|
||||
sidebartabextension: {
|
||||
title: 'SidebarTabExtension',
|
||||
description: 'Register a custom sidebar tab.',
|
||||
group: 'shell',
|
||||
order: 40
|
||||
},
|
||||
bottompanelextension: {
|
||||
title: 'BottomPanelExtension',
|
||||
description: 'Register a custom bottom panel tab.',
|
||||
group: 'shell',
|
||||
order: 41
|
||||
},
|
||||
toastmanager: {
|
||||
title: 'ToastManager',
|
||||
description: 'Show toast notifications to the user.',
|
||||
group: 'shell',
|
||||
order: 42
|
||||
},
|
||||
commandmanager: {
|
||||
title: 'CommandManager',
|
||||
description: 'Register keyboard shortcuts and command palette entries.',
|
||||
group: 'shell',
|
||||
order: 43
|
||||
},
|
||||
extensionmanager: {
|
||||
title: 'ExtensionManager',
|
||||
description: 'Access shell UI registration APIs.',
|
||||
group: 'shell',
|
||||
order: 44
|
||||
},
|
||||
// Identity
|
||||
nodelocatorid: {
|
||||
title: 'NodeLocatorId',
|
||||
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
|
||||
group: 'identity',
|
||||
order: 50
|
||||
},
|
||||
nodeexecutionid: {
|
||||
title: 'NodeExecutionId',
|
||||
description: 'Branded string ID for a specific node execution run.',
|
||||
group: 'identity',
|
||||
order: 51
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<PageMeta['group'], string> = {
|
||||
root: 'Extensions API',
|
||||
core: 'Registration',
|
||||
handles: 'Handles',
|
||||
events: 'Events',
|
||||
shell: 'Shell UI',
|
||||
identity: 'Identity'
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function slug(stem: string): string {
|
||||
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function metaFor(stem: string): PageMeta {
|
||||
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
|
||||
return (
|
||||
PAGE_META[key] ?? {
|
||||
title: stem,
|
||||
description: `API reference for ${stem}.`,
|
||||
group: 'core',
|
||||
order: 99
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
|
||||
function toMintlifyMdx(raw: string, stem: string): string {
|
||||
const meta = metaFor(stem)
|
||||
|
||||
// Build frontmatter
|
||||
const fm: string[] = [
|
||||
`---`,
|
||||
`title: "${meta.title}"`,
|
||||
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
|
||||
`description: "${meta.description}"`,
|
||||
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
|
||||
`---`
|
||||
]
|
||||
|
||||
let body = raw
|
||||
|
||||
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
|
||||
|
||||
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
|
||||
body = body.replace(/^# .+\n+/, '')
|
||||
|
||||
// Ensure opening code fences that have no lang tag get `ts`
|
||||
// Only match a ``` that is immediately followed by a newline (opening fence),
|
||||
// not a closing fence (which also has just ``` + newline but we can detect
|
||||
// by context: opening fences follow non-fence lines; closing fences follow content).
|
||||
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
|
||||
// We track state via a flag pass instead of a single regex.
|
||||
let inBlock = false
|
||||
body = body
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (inBlock) {
|
||||
if (line.trim() === '```') { inBlock = false; return line }
|
||||
return line
|
||||
}
|
||||
if (line.startsWith('```')) {
|
||||
if (line.trim() === '```') {
|
||||
// bare opening fence → add ts
|
||||
inBlock = true
|
||||
return '```ts'
|
||||
}
|
||||
// has a lang tag already
|
||||
inBlock = true
|
||||
return line
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// TypeDoc emits `typescript` lang tag; normalize to `ts`
|
||||
body = body.replace(/^```typescript\b/gm, '```ts')
|
||||
|
||||
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
|
||||
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
|
||||
(_match, label, _category, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
// Same-dir links
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
|
||||
(_match, label, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
|
||||
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
|
||||
// code examples to be directly under prose without a sub-heading.
|
||||
// Flatten "## Example\n\n```ts" → "```ts"
|
||||
body = body.replace(/^## Example\s*\n+/gm, '')
|
||||
|
||||
// Stability tags: render as a <Tip> callout
|
||||
body = body.replace(
|
||||
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
|
||||
(_match, level) => {
|
||||
const label =
|
||||
level === 'stable'
|
||||
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
|
||||
: level === 'experimental'
|
||||
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
|
||||
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
|
||||
return label
|
||||
}
|
||||
)
|
||||
|
||||
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
|
||||
body = body.replace(
|
||||
/^Stability: (stable|experimental|deprecated)\s*$/gm,
|
||||
(_match, level) => {
|
||||
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
|
||||
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
|
||||
return '<Warning>**Stability:** Deprecated</Warning>'
|
||||
}
|
||||
)
|
||||
|
||||
return [...fm, '', body.trim(), ''].join('\n')
|
||||
}
|
||||
|
||||
// ── Nav snippet builder ───────────────────────────────────────────────────────
|
||||
|
||||
interface NavPage {
|
||||
group?: string
|
||||
pages: (string | NavPage)[]
|
||||
}
|
||||
|
||||
function buildNavSnippet(stems: string[]): NavPage {
|
||||
// Sort stems by order then group by category
|
||||
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
|
||||
const sortedByGroup: Record<string, string[]> = {}
|
||||
for (const stem of sortedStems) {
|
||||
const group = metaFor(stem).group
|
||||
if (!sortedByGroup[group]) sortedByGroup[group] = []
|
||||
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
|
||||
}
|
||||
|
||||
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
|
||||
|
||||
const pages: (string | NavPage)[] = []
|
||||
|
||||
// Overview at top level
|
||||
if (sortedByGroup['root']) {
|
||||
for (const p of sortedByGroup['root']) pages.push(p)
|
||||
}
|
||||
|
||||
for (const grp of groupOrder) {
|
||||
if (grp === 'root') continue
|
||||
const grpPages = sortedByGroup[grp]
|
||||
if (!grpPages?.length) continue
|
||||
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
|
||||
}
|
||||
|
||||
return { group: 'Extensions API', pages }
|
||||
}
|
||||
|
||||
// ── Main pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTypedoc(): void {
|
||||
console.log('▶ Running TypeDoc...')
|
||||
execSync(
|
||||
`pnpm exec typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
|
||||
{ cwd: pkgRoot, stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
function processFiles(): void {
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(mintlifyDir, { recursive: true })
|
||||
|
||||
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
|
||||
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
|
||||
|
||||
const stems: string[] = []
|
||||
|
||||
for (const relPath of mdFiles) {
|
||||
const src = path.join(rawDir, relPath)
|
||||
const stem = path.basename(relPath, '.md')
|
||||
const raw = fs.readFileSync(src, 'utf8')
|
||||
const mdx = toMintlifyMdx(raw, stem)
|
||||
|
||||
const destName = slug(stem) + '.mdx'
|
||||
const dest = path.join(mintlifyDir, destName)
|
||||
fs.writeFileSync(dest, mdx)
|
||||
console.log(` ✔ ${relPath} → mintlify/${destName}`)
|
||||
stems.push(stem)
|
||||
}
|
||||
|
||||
// Write nav snippet
|
||||
const nav = buildNavSnippet(stems)
|
||||
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
|
||||
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
|
||||
console.log(` ✔ nav-snippet.json`)
|
||||
|
||||
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
|
||||
console.log(` ${stems.length} pages + nav-snippet.json`)
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
runTypedoc()
|
||||
processFiles()
|
||||
}
|
||||
|
||||
if (watchMode) {
|
||||
// Simple watch: re-run on change to source files
|
||||
console.log('👁 Watch mode — watching src/extension-api/**')
|
||||
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
run()
|
||||
|
||||
fs.watch(srcDir, { recursive: true }, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
console.log('\n🔄 Source changed — rebuilding...')
|
||||
try { run() } catch (e) { console.error(e) }
|
||||
}, 500)
|
||||
})
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
78
packages/extension-api/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @comfyorg/extension-api — Public Extension API for ComfyUI
|
||||
*
|
||||
* This is the package entry point compiled to `build/index.js` + `build/index.d.ts`.
|
||||
* It re-exports the stable public contract from `src/extension-api/` in the main app.
|
||||
*
|
||||
* All types and functions exported here are part of the semver-stable surface.
|
||||
* Do not add internal implementation details to this barrel.
|
||||
*/
|
||||
|
||||
// Re-export everything from the canonical source in the main app tree.
|
||||
// The tsconfig.json paths alias @/* → ../../src/* so these resolve correctly.
|
||||
export type {
|
||||
ExtensionOptions,
|
||||
NodeExtensionOptions,
|
||||
WidgetExtensionOptions
|
||||
} from '@/extension-api/lifecycle'
|
||||
|
||||
export {
|
||||
defineExtension,
|
||||
defineNodeExtension,
|
||||
defineWidgetExtension,
|
||||
onNodeMounted,
|
||||
onNodeRemoved
|
||||
} from '@/extension-api/lifecycle'
|
||||
|
||||
export type {
|
||||
NodeHandle,
|
||||
NodeEntityId,
|
||||
SlotEntityId,
|
||||
SlotInfo,
|
||||
SlotDirection,
|
||||
NodeMode,
|
||||
Point,
|
||||
Size,
|
||||
NodeExecutedEvent,
|
||||
NodeConnectedEvent,
|
||||
NodeDisconnectedEvent,
|
||||
NodePositionChangedEvent,
|
||||
NodeSizeChangedEvent,
|
||||
NodeModeChangedEvent,
|
||||
NodeBeforeSerializeEvent
|
||||
} from '@/extension-api/node'
|
||||
|
||||
export type {
|
||||
WidgetHandle,
|
||||
WidgetEntityId,
|
||||
WidgetValue,
|
||||
WidgetOptions,
|
||||
WidgetValueChangeEvent,
|
||||
WidgetOptionChangeEvent,
|
||||
WidgetPropertyChangeEvent,
|
||||
WidgetBeforeSerializeEvent,
|
||||
WidgetBeforeQueueEvent
|
||||
} from '@/extension-api/widget'
|
||||
|
||||
export type { Handler, AsyncHandler, Unsubscribe } from '@/extension-api/events'
|
||||
|
||||
export type {
|
||||
SidebarTabExtension,
|
||||
BottomPanelExtension,
|
||||
VueExtension,
|
||||
CustomExtension,
|
||||
ToastMessageOptions,
|
||||
ToastManager,
|
||||
ExtensionManager,
|
||||
CommandManager
|
||||
} from '@/extension-api/shell'
|
||||
|
||||
export type { NodeLocatorId, NodeExecutionId } from '@/extension-api/identifiers'
|
||||
export {
|
||||
isNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
parseNodeLocatorId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
createNodeExecutionId
|
||||
} from '@/extension-api/identifiers'
|
||||
4
packages/extension-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**", "scripts/**"]
|
||||
}
|
||||
21
packages/extension-api/tsconfig.docs.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../src/extension-api/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../../src/**/*.test.ts",
|
||||
"../../src/**/*.spec.ts",
|
||||
"../../src/**/*.vue"
|
||||
]
|
||||
}
|
||||
19
packages/extension-api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts", "scripts/**"]
|
||||
}
|
||||
37
packages/extension-api/typedoc.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"entryPoints": ["../../src/extension-api/index.ts"],
|
||||
"tsconfig": "./tsconfig.docs.json",
|
||||
"out": "./docs-build/raw",
|
||||
"plugin": ["typedoc-plugin-markdown"],
|
||||
"excludeInternal": true,
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"readme": "none",
|
||||
"skipErrorChecking": true,
|
||||
"githubPages": false,
|
||||
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
|
||||
"hideGenerator": true,
|
||||
"useCodeBlocks": true,
|
||||
"flattenOutputFiles": false,
|
||||
"entryFileName": "index",
|
||||
"fileExtension": ".md",
|
||||
"outputFileStrategy": "members",
|
||||
"hidePageHeader": false,
|
||||
"hideBreadcrumbs": false,
|
||||
"useHTMLAnchors": false,
|
||||
"sanitizeComments": true,
|
||||
"expandObjects": false,
|
||||
"parametersFormat": "table",
|
||||
"propertiesFormat": "table",
|
||||
"typeDeclarationFormat": "table",
|
||||
"indexFormat": "table",
|
||||
"tableColumnSettings": {
|
||||
"hideDefaults": false,
|
||||
"hideInherited": false,
|
||||
"hideModifiers": false,
|
||||
"hideOverrides": false,
|
||||
"hideSources": true,
|
||||
"hideValues": false,
|
||||
"leftAlignHeaders": false
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
328
pnpm-lock.yaml
generated
@@ -650,7 +650,7 @@ importers:
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
'@nx/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)
|
||||
'@pinia/testing':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))
|
||||
@@ -662,7 +662,7 @@ importers:
|
||||
version: 4.6.0
|
||||
'@storybook/addon-docs':
|
||||
specifier: 'catalog:'
|
||||
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
@@ -671,10 +671,10 @@ importers:
|
||||
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@storybook/vue3-vite':
|
||||
specifier: 'catalog:'
|
||||
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@testing-library/jest-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 6.9.1
|
||||
@@ -704,7 +704,7 @@ importers:
|
||||
version: 0.170.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(vitest@4.0.16)
|
||||
@@ -842,19 +842,19 @@ importers:
|
||||
version: 11.1.0
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-dts:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-html:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6
|
||||
@@ -912,10 +912,10 @@ importers:
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
dotenv:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.1
|
||||
@@ -927,13 +927,13 @@ importers:
|
||||
version: 30.0.0(@babel/parser@7.29.0)(vue@3.5.13(typescript@5.9.3))
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-html:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -982,16 +982,16 @@ importers:
|
||||
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
|
||||
'@astrojs/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
|
||||
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.4)
|
||||
'@playwright/test':
|
||||
specifier: 'catalog:'
|
||||
version: 1.58.1
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
astro:
|
||||
specifier: 'catalog:'
|
||||
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
|
||||
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4)
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0
|
||||
@@ -1003,7 +1003,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
@@ -1030,6 +1030,21 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/extension-api:
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.4
|
||||
typedoc:
|
||||
specifier: 0.28.19
|
||||
version: 0.28.19(typescript@5.9.3)
|
||||
typedoc-plugin-markdown:
|
||||
specifier: ^4.6.3
|
||||
version: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/ingest-types:
|
||||
dependencies:
|
||||
zod:
|
||||
@@ -2431,6 +2446,9 @@ packages:
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
@@ -5453,6 +5471,10 @@ packages:
|
||||
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7624,6 +7646,9 @@ packages:
|
||||
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
||||
engines: {node: '>=16.14'}
|
||||
|
||||
lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -7864,6 +7889,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -9343,6 +9372,19 @@ packages:
|
||||
typed-binary@4.3.2:
|
||||
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
|
||||
|
||||
typedoc-plugin-markdown@4.11.0:
|
||||
resolution: {integrity: sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
typedoc: 0.28.x
|
||||
|
||||
typedoc@0.28.19:
|
||||
resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==}
|
||||
engines: {node: '>= 18', pnpm: '>= 10'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x
|
||||
|
||||
typegpu@0.8.2:
|
||||
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -10186,6 +10228,11 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -10467,14 +10514,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
|
||||
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.4)':
|
||||
dependencies:
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/compiler-sfc': 3.5.28
|
||||
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
@@ -11863,6 +11910,14 @@ snapshots:
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
@@ -12495,11 +12550,11 @@ snapshots:
|
||||
- typescript
|
||||
- verdaccio
|
||||
|
||||
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
|
||||
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)':
|
||||
dependencies:
|
||||
'@nx/devkit': 22.6.1(nx@22.6.1)
|
||||
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
|
||||
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
|
||||
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)
|
||||
'@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3)
|
||||
ajv: 8.18.0
|
||||
enquirer: 2.3.6
|
||||
@@ -12507,8 +12562,8 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
tsconfig-paths: 4.2.0
|
||||
tslib: 2.8.1
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -12519,7 +12574,7 @@ snapshots:
|
||||
- typescript
|
||||
- verdaccio
|
||||
|
||||
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
|
||||
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)':
|
||||
dependencies:
|
||||
'@nx/devkit': 22.6.1(nx@22.6.1)
|
||||
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
|
||||
@@ -12527,8 +12582,8 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -13317,10 +13372,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4)
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
react: 19.2.4
|
||||
@@ -13346,25 +13401,25 @@ snapshots:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
ts-dedent: 2.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- esbuild
|
||||
- rollup
|
||||
- webpack
|
||||
|
||||
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
esbuild: 0.27.3
|
||||
rollup: 4.53.5
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
|
||||
@@ -13389,14 +13444,14 @@ snapshots:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
|
||||
magic-string: 0.30.21
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
typescript: 5.9.3
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue-component-meta: 2.2.12(typescript@5.9.3)
|
||||
vue-docgen-api: 4.79.2(vue@3.5.13(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
@@ -13478,19 +13533,19 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.0
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.0
|
||||
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.0
|
||||
'@tailwindcss/oxide': 4.2.0
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.0
|
||||
'@tailwindcss/oxide': 4.2.0
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
@@ -14083,32 +14138,32 @@ snapshots:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@rolldown/pluginutils': 1.0.0-rc.9
|
||||
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitest/coverage-v8@4.0.16(vitest@4.0.16)':
|
||||
@@ -14124,7 +14179,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14145,21 +14200,21 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -14195,7 +14250,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -14370,38 +14425,38 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
|
||||
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
'@vue/devtools-shared': 7.7.9
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
@@ -14809,7 +14864,7 @@ snapshots:
|
||||
|
||||
astral-regex@2.0.0: {}
|
||||
|
||||
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
|
||||
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
@@ -14866,8 +14921,8 @@ snapshots:
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.4
|
||||
vfile: 6.0.3
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
@@ -15060,6 +15115,10 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
@@ -17463,6 +17522,8 @@ snapshots:
|
||||
|
||||
lru-cache@8.0.5: {}
|
||||
|
||||
lunr@2.3.9: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
lz-utils@2.1.0: {}
|
||||
@@ -17898,6 +17959,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -19827,6 +19892,19 @@ snapshots:
|
||||
|
||||
typed-binary@4.3.2: {}
|
||||
|
||||
typedoc-plugin-markdown@4.11.0(typedoc@0.28.19(typescript@5.9.3)):
|
||||
dependencies:
|
||||
typedoc: 0.28.19(typescript@5.9.3)
|
||||
|
||||
typedoc@0.28.19(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@gerrit0/mini-shiki': 3.23.0
|
||||
lunr: 2.3.9
|
||||
markdown-it: 14.1.1
|
||||
minimatch: 10.2.5
|
||||
typescript: 5.9.3
|
||||
yaml: 2.8.4
|
||||
|
||||
typegpu@0.8.2:
|
||||
dependencies:
|
||||
tinyest: 0.1.2
|
||||
@@ -20111,27 +20189,27 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
birpc: 2.9.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
birpc: 2.9.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@microsoft/api-extractor': 7.57.2(@types/node@24.10.4)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
|
||||
@@ -20144,13 +20222,13 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
typescript: 5.9.3
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
colorette: 2.0.20
|
||||
@@ -20164,9 +20242,9 @@ snapshots:
|
||||
html-minifier-terser: 6.1.0
|
||||
node-html-parser: 5.4.2
|
||||
pathe: 0.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
colorette: 2.0.20
|
||||
@@ -20180,9 +20258,9 @@ snapshots:
|
||||
html-minifier-terser: 6.1.0
|
||||
node-html-parser: 5.4.2
|
||||
pathe: 0.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@antfu/utils': 0.7.10
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
|
||||
@@ -20193,12 +20271,12 @@ snapshots:
|
||||
perfect-debounce: 1.0.0
|
||||
picocolors: 1.1.1
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
debug: 4.4.3
|
||||
@@ -20208,12 +20286,12 @@ snapshots:
|
||||
perfect-debounce: 2.0.0
|
||||
sirv: 3.0.2
|
||||
unplugin-utils: 0.3.1
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
debug: 4.4.3
|
||||
@@ -20223,56 +20301,56 @@ snapshots:
|
||||
perfect-debounce: 2.0.0
|
||||
sirv: 3.0.2
|
||||
unplugin-utils: 0.3.1
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
'@vue/devtools-shared': 7.7.9
|
||||
execa: 9.6.1
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- rollup
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -20283,11 +20361,11 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.28
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -20298,11 +20376,11 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.28
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@@ -20317,9 +20395,9 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
terser: 5.39.2
|
||||
tsx: 4.19.4
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.4
|
||||
|
||||
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@@ -20334,16 +20412,16 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
terser: 5.39.2
|
||||
tsx: 4.19.4
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.4
|
||||
|
||||
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20360,7 +20438,7 @@ snapshots:
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -20382,10 +20460,10 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20402,7 +20480,7 @@ snapshots:
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -20834,6 +20912,8 @@ snapshots:
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
28
scripts/generate-docs.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# PKG5.D6 — Generate TypeDoc → Mintlify MDX for @comfyorg/extension-api
|
||||
#
|
||||
# Output: packages/extension-api/docs-build/mintlify/*.mdx
|
||||
# packages/extension-api/docs-build/mintlify/nav-snippet.json
|
||||
#
|
||||
# Prerequisites: pnpm install must have been run (typedoc, tsx)
|
||||
# Usage: ./scripts/generate-docs.sh [--watch]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PKG_DIR="$REPO_ROOT/packages/extension-api"
|
||||
|
||||
if [ ! -f "$PKG_DIR/package.json" ]; then
|
||||
echo "ERROR: $PKG_DIR/package.json not found — run from repo root or ensure packages/extension-api exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--watch" ]; then
|
||||
echo "Starting docs watch mode..."
|
||||
pnpm --filter @comfyorg/extension-api docs:watch
|
||||
else
|
||||
echo "Generating extension API docs..."
|
||||
pnpm --filter @comfyorg/extension-api docs:build
|
||||
echo ""
|
||||
echo "Done. MDX files written to: $PKG_DIR/docs-build/mintlify/"
|
||||
echo "Copy to Comfy-Org/docs: cp -r $PKG_DIR/docs-build/mintlify/* <docs-repo>/extensions/api/"
|
||||
fi
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
@@ -115,6 +116,15 @@ def generate_av_fixture(
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_png():
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('workflow', WORKFLOW_JSON)
|
||||
info.add_text('prompt', PROMPT_JSON)
|
||||
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_metadata.png')
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
@@ -167,6 +177,7 @@ def generate_webm():
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -48,6 +51,7 @@
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -62,6 +66,7 @@
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -76,6 +81,7 @@
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -92,6 +98,7 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
192
src/components/dialog/GlobalDialog.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const Body = defineComponent({
|
||||
name: 'Body',
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
function mountDialog() {
|
||||
return render(GlobalDialog, {
|
||||
global: { plugins: [PrimeVue, i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GlobalDialog renderer branching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is reka', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-opt-in',
|
||||
title: 'Reka dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the renderer flag on the dialog stack item', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-flag-check',
|
||||
title: 'Reka',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
|
||||
expect(item?.dialogComponentProps.renderer).toBe('reka')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('omits the close button when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-not-closable',
|
||||
title: 'No close',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the close button by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-closable',
|
||||
title: 'Closable',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the title when headless is true', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-headless',
|
||||
title: 'Hidden title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', headless: true }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByText('Hidden title')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the title when headless is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-titled',
|
||||
title: 'Visible title',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.getByText('Visible title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dialog on Escape by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-default',
|
||||
title: 'Esc closes',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka' }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not close on Escape when closable is false', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-esc-blocked',
|
||||
title: 'Esc blocked',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', closable: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,107 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="item in dialogStore.dialogStack" :key="item.key">
|
||||
<Dialog
|
||||
v-if="isRekaItem(item)"
|
||||
:open="item.visible"
|
||||
:modal="item.dialogComponentProps.modal ?? true"
|
||||
@update:open="(open) => onRekaOpenChange(item.key, open)"
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
item.dialogComponentProps.dismissableMask === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<DialogHeader v-if="!item.dialogComponentProps.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<DialogTitle v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</DialogTitle>
|
||||
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
<PrimeDialog
|
||||
v-else
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
<div v-if="!item.dialogComponentProps?.headless">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
v-bind="item.headerProps"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</PrimeDialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
|
||||
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
|
||||
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
|
||||
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
|
||||
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -53,6 +111,14 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function isRekaItem(item: DialogInstance) {
|
||||
return item.dialogComponentProps.renderer === 'reka'
|
||||
}
|
||||
|
||||
function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
@@ -12,7 +13,23 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
overwrite: 'Overwrite',
|
||||
save: 'Save',
|
||||
no: 'No',
|
||||
ok: 'OK',
|
||||
close: 'Close'
|
||||
},
|
||||
desktopMenu: {
|
||||
reinstall: 'Reinstall'
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -23,10 +40,9 @@ describe('ConfirmationDialogContent', () => {
|
||||
})
|
||||
|
||||
function renderComponent(props: Partial<Props> = {}) {
|
||||
return render(ConfirmationDialogContent, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
},
|
||||
const user = userEvent.setup()
|
||||
render(ConfirmationDialogContent, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
message: 'Test message',
|
||||
type: 'default',
|
||||
@@ -34,6 +50,7 @@ describe('ConfirmationDialogContent', () => {
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('renders long messages without breaking layout', () => {
|
||||
@@ -42,4 +59,104 @@ describe('ConfirmationDialogContent', () => {
|
||||
renderComponent({ message: longFilename })
|
||||
expect(screen.getByText(longFilename)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the hint as a status alert when provided', () => {
|
||||
renderComponent({ hint: 'This action cannot be undone.' })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('This action cannot be undone.')
|
||||
})
|
||||
|
||||
it('does not render a status alert when hint is omitted', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('button surface per type', () => {
|
||||
it("type='default' renders Cancel and Confirm", () => {
|
||||
renderComponent({ type: 'default' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Confirm' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='delete' renders Cancel and Delete", () => {
|
||||
renderComponent({ type: 'delete' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='overwrite' renders Cancel and Overwrite", () => {
|
||||
renderComponent({ type: 'overwrite' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Overwrite' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='info' renders only OK (no Cancel)", () => {
|
||||
renderComponent({ type: 'info' })
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('confirm callback receives true and closes the dialog', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ type: 'default', onConfirm })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('dirtyClose deny label', () => {
|
||||
it('uses the provided denyLabel for the deny button', () => {
|
||||
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
|
||||
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'No' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "No" when denyLabel is not provided', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm(false) when deny is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
type: 'dirtyClose',
|
||||
denyLabel: 'Close anyway',
|
||||
onConfirm
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onConfirm(true) when save is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
<div
|
||||
v-if="hint"
|
||||
class="mt-2"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
role="status"
|
||||
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
|
||||
<span>{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-4">
|
||||
<div
|
||||
@@ -55,7 +53,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="type !== 'info'"
|
||||
v-if="type !== 'info' && type !== 'dirtyClose'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
@@ -86,9 +84,9 @@
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button variant="secondary" @click="onDeny">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.no') }}
|
||||
{{ denyLabel ?? $t('g.no') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<Button autofocus @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
@@ -115,7 +113,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -131,6 +128,7 @@ const props = defineProps<{
|
||||
onConfirm: (value?: boolean) => void
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
denyLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
@@ -15,10 +18,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
:content-style="keybindingOverlayContentStyle"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
:style="keybindingOverlayContentStyle"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
@@ -238,6 +243,7 @@
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
@@ -314,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -337,6 +344,8 @@ const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<SelectContent
|
||||
:style="contentStyle"
|
||||
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
|
||||
>
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
@@ -46,6 +49,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames } = defineProps<{
|
||||
const { presetNames, contentStyle } = defineProps<{
|
||||
presetNames: string[]
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
164
src/components/helpcenter/HelpCenterMenuContent.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const commandStoreExecute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: { discord: '', github: '' },
|
||||
buildDocsUrl: () => 'https://docs.comfy.org'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackHelpCenterOpened: vi.fn(),
|
||||
trackHelpCenterClosed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
releases: [],
|
||||
recentReleases: [],
|
||||
isLoading: false,
|
||||
fetchReleases: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: commandStoreExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PuzzleIconStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(HelpCenterMenuContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('HelpCenterMenuContent feedback item', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
commandStoreExecute.mockReset()
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
buildFeedbackTypeformUrl('help-center'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -21,20 +21,42 @@
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
|
||||
@update:model-value="(val) => (selectedSpeed = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in speedOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
:model-value="
|
||||
selectedAnimation != null ? String(selectedAnimation) : undefined
|
||||
"
|
||||
@update:model-value="(val) => (selectedAnimation = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="anim in animations"
|
||||
:key="anim.index"
|
||||
:value="String(anim.index)"
|
||||
>
|
||||
{{ anim.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
@@ -54,10 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -5,20 +5,20 @@ import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="modelValue"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
const {
|
||||
icon = 'pi-expand',
|
||||
@@ -47,6 +48,12 @@ const {
|
||||
const value = defineModel<number>()
|
||||
const showSlider = ref(false)
|
||||
|
||||
const sliderValue = computed(() => [value.value ?? min])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) value.value = val[0]
|
||||
}
|
||||
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
|
||||
@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,34 +2,46 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="cameraType">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="cam in cameras"
|
||||
:key="cam.value"
|
||||
:value="cam.value"
|
||||
>
|
||||
{{ cam.title }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
:model-value="fovSliderValue"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('load3d.fov')"
|
||||
@update:model-value="onFovUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -41,4 +53,10 @@ const cameras = [
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const fovSliderValue = computed(() => [fov.value ?? 10])
|
||||
|
||||
function onFovUpdate(val: number[] | undefined) {
|
||||
if (val?.length) fov.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Select
|
||||
v-model="exportFormat"
|
||||
:options="exportFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="exportFormat">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in exportFormats"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@@ -19,10 +26,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
|
||||
@@ -17,19 +17,20 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
|
||||
const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => [
|
||||
lightIntensity.value ?? lightIntensityMinimum
|
||||
])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) lightIntensity.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,20 +9,65 @@ import type {
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -2,31 +2,51 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
<Select v-model="upDirection">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in upDirectionOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
<Select v-model="materialMode">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in materialModeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
|
||||