From e009fcb4605eeacf95191c74a886a26ce81038e3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 15:19:13 +0200 Subject: [PATCH 01/15] =?UTF-8?q?chore(svelte5):=20slice=201=20=E2=80=94?= =?UTF-8?q?=20bump=20to=20svelte=205=20+=20svelte-check=204,=20wire=20comp?= =?UTF-8?q?onent-test=20infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - svelte ^4.2.20 -> ^5; svelte-check ^3.8.6 -> ^4 - add @sveltejs/vite-plugin-svelte ^6 (vite 7 compat; NOT ^5/^7), @testing-library/svelte+jest-dom+user-event, eslint-plugin-svelte + svelte-eslint-parser - tsconfig: skipLibCheck:true so svelte-check matches the build's tsc -skipLibCheck (third-party .d.ts only; our source still fully checked) - vitest: svelte() + svelteTesting() plugins + jest-dom setup - obsidian-stub: add module-level setIcon (emits ) + prepareFuzzySearch - smoke gate: ObsidianIcon renders via the new pipeline; svelte-dnd-action exports resolve All components remain legacy (Svelte 4 syntax) and compile under Svelte 5 legacy mode. --- bun.lock | 150 ++++++++++++------ package.json | 10 +- .../components/svelte5-pipeline.smoke.test.ts | 43 +++++ tests/obsidian-stub.ts | 27 ++++ tests/vitest-setup.ts | 3 + tsconfig.json | 3 +- vitest.config.mts | 5 +- 7 files changed, 188 insertions(+), 53 deletions(-) create mode 100644 src/gui/components/svelte5-pipeline.smoke.test.ts create mode 100644 tests/vitest-setup.ts diff --git a/bun.lock b/bun.lock index d2b1ea7b..e9f90e90 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,10 @@ "@popperjs/core": "^2.11.8", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", + "@sveltejs/vite-plugin-svelte": "^6", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "24.0.12", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", @@ -22,15 +26,17 @@ "esbuild": "0.28", "esbuild-svelte": "^0.9.5", "eslint": "10", + "eslint-plugin-svelte": "^3.18.0", "globals": "17", "jsdom": "29", "obsidian": "1.11.4", "obsidian-dataview": "^0.5.68", "obsidian-e2e": "0.6.0", "semantic-release": "^24.2.9", - "svelte": "^4.2.20", - "svelte-check": "^3.8.6", + "svelte": "^5", + "svelte-check": "^4", "svelte-dnd-action": "0.9.69", + "svelte-eslint-parser": "^1.6.1", "svelte-preprocess": "^6.0.5", "three-way-merge": "^0.1.0", "tslib": "^2.8.1", @@ -42,6 +48,8 @@ }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -60,6 +68,8 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], @@ -180,6 +190,8 @@ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], @@ -394,6 +406,24 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/svelte": ["@testing-library/svelte@5.3.1", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="], + + "@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="], @@ -410,10 +440,10 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], - "@types/pug": ["@types/pug@2.0.10", "", {}, "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA=="], - "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="], @@ -488,8 +518,6 @@ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argv-formatter": ["argv-formatter@1.0.0", "", {}, "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw=="], @@ -514,8 +542,6 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -526,8 +552,6 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cachedir": ["cachedir@2.3.0", "", {}, "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw=="], @@ -544,7 +568,7 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chrono-node": ["chrono-node@2.9.1", "", {}, "sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg=="], @@ -564,7 +588,7 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -604,6 +628,10 @@ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], @@ -620,16 +648,24 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-file": ["detect-file@1.0.0", "", {}, "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], @@ -654,8 +690,6 @@ "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], - "es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="], - "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "esbuild-svelte": ["esbuild-svelte@0.9.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.19" }, "peerDependencies": { "esbuild": ">=0.17.0", "svelte": ">=4.2.1 <6" } }, "sha512-16FUSj64aiS28CCxYeK3hVxCyU4PwGBSkeArawAtnWSV7l0Gc+frIOP88MNj8Q7tDAGyEJAHT6OpOvM24BG46w=="], @@ -666,14 +700,20 @@ "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.18.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-vc3P37lrDronWDb2kPXiG8sqkuiMqitGXSSaflb7Y+jpDgNoAzW8i7tdqyJKpcLZmFIqZCD+je2oZRf9qyRyBw=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + "esrap": ["esrap@2.2.9", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="], + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -812,8 +852,6 @@ "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -884,6 +922,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], @@ -912,6 +952,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], @@ -948,6 +990,8 @@ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], @@ -982,8 +1026,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - "moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1008,8 +1050,6 @@ "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], "npm": ["npm@10.9.8", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.5", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.3", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.5", "libnpmexec": "^9.0.5", "libnpmfund": "^6.0.5", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.5", "libnpmpublish": "^10.0.2", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.9", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.5.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.1", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.4", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^7.5.11", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.2", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-fYwb6ODSmHkqrJQQaCxY3M2lPf/mpgC7ik0HSzzIwG5CGtabRp4bNqikatvCoT42b5INQSqudVH0R7yVmC9hVg=="], @@ -1098,8 +1138,6 @@ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1114,10 +1152,20 @@ "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -1128,13 +1176,17 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], @@ -1150,8 +1202,6 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], @@ -1164,8 +1214,6 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sander": ["sander@0.5.1", "", { "dependencies": { "es6-promise": "^3.1.2", "graceful-fs": "^4.1.3", "mkdirp": "^0.5.1", "rimraf": "^2.5.2" } }, "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA=="], - "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="], @@ -1190,8 +1238,6 @@ "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], - "sorcery": ["sorcery@0.11.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", "buffer-crc32": "^1.0.0", "minimist": "^1.2.0", "sander": "^0.5.0" }, "bin": { "sorcery": "bin/sorcery" } }, "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1242,12 +1288,14 @@ "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], - "svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="], + "svelte": ["svelte@5.55.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg=="], - "svelte-check": ["svelte-check@3.8.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", "picocolors": "^1.0.0", "sade": "^1.7.4", "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "peerDependencies": { "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q=="], + "svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="], "svelte-dnd-action": ["svelte-dnd-action@0.9.69", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw=="], + "svelte-eslint-parser": ["svelte-eslint-parser@1.6.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-hhvSH6kRj46UzrBVO5TaotD+Iuvruj5ccKBcO4wAhVcPTLmIc/c32D8UllBTYO0on4LzYuM0rNzf1lM/gBlkSQ=="], + "svelte-preprocess": ["svelte-preprocess@6.0.5", "", { "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", "postcss-load-config": ">=3", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": ">=0.55", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", "svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["@babel/core", "coffeescript", "less", "postcss", "postcss-load-config", "pug", "sass", "stylus", "sugarss", "typescript"] }, "sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -1342,6 +1390,8 @@ "vite-plus": ["vite-plus@0.1.20", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@voidzero-dev/vite-plus-core": "0.1.20", "@voidzero-dev/vite-plus-test": "0.1.20", "oxfmt": "=0.46.0", "oxlint": "=1.61.0", "oxlint-tsgolint": "=0.22.0" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.1.20", "@voidzero-dev/vite-plus-darwin-x64": "0.1.20", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.1.20", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.1.20", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.1.20", "@voidzero-dev/vite-plus-linux-x64-musl": "0.1.20", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.1.20", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.1.20" }, "bin": { "vp": "bin/vp", "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint" } }, "sha512-hxJqXTxiiFhszwAeD0MvKlztVuXE4TztTdJ64BPxGqgY67F0PDa5eZkUsrN91Ae8aYUMfweW6V/J57OUO9/0zw=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], @@ -1392,6 +1442,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1424,20 +1476,20 @@ "@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@voidzero-dev/vite-plus-test/std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "@voidzero-dev/vite-plus-test/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -1454,6 +1506,8 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1858,16 +1912,18 @@ "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], + "postcss-load-config/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], @@ -1884,9 +1940,13 @@ "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "svelte/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - "svelte-check/svelte-preprocess": ["svelte-preprocess@5.1.4", "", { "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@babel/core", "coffeescript", "less", "postcss", "postcss-load-config", "pug", "sass", "stylus", "sugarss", "typescript"] }, "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA=="], + "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], @@ -1990,16 +2050,12 @@ "pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], - "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "semantic-release/aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "signale/figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "svelte/css-tree/mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], - "vite-node/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -2100,8 +2156,6 @@ "pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -2223,7 +2277,5 @@ "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], - - "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/package.json b/package.json index 52f73a09..c2e2a36e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "@popperjs/core": "^2.11.8", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", + "@sveltejs/vite-plugin-svelte": "^6", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "24.0.12", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", @@ -28,15 +32,17 @@ "esbuild": "0.28", "esbuild-svelte": "^0.9.5", "eslint": "10", + "eslint-plugin-svelte": "^3.18.0", "globals": "17", "jsdom": "29", "obsidian": "1.11.4", "obsidian-dataview": "^0.5.68", "obsidian-e2e": "0.6.0", "semantic-release": "^24.2.9", - "svelte": "^4.2.20", - "svelte-check": "^3.8.6", + "svelte": "^5", + "svelte-check": "^4", "svelte-dnd-action": "0.9.69", + "svelte-eslint-parser": "^1.6.1", "svelte-preprocess": "^6.0.5", "three-way-merge": "^0.1.0", "tslib": "^2.8.1", diff --git a/src/gui/components/svelte5-pipeline.smoke.test.ts b/src/gui/components/svelte5-pipeline.smoke.test.ts new file mode 100644 index 00000000..9446c7f6 --- /dev/null +++ b/src/gui/components/svelte5-pipeline.smoke.test.ts @@ -0,0 +1,43 @@ +/** + * Slice 1 smoke gate for the Svelte 5 rewrite. + * + * Proves the new test pipeline works end-to-end BEFORE any component is rewritten: + * - @sveltejs/vite-plugin-svelte compiles a real .svelte component under vitest + * - @testing-library/svelte v5 mounts it (Svelte 5 mount()) in jsdom + * - the obsidian alias/stub supplies the standalone setIcon() the component imports + * - svelte-dnd-action's load-bearing exports still resolve under the new resolver + * conditions (svelteTesting adds the 'browser' condition; the lib also exposes a + * 'svelte' source condition — confirm we don't pull a broken build) + * + * ObsidianIcon is still a legacy (Svelte 4 syntax) component here; that it renders + * confirms legacy + runes components coexist in one compile, which the atomic rewrite + * relies on while clusters are converted. + */ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/svelte"; +import { mount, unmount } from "svelte"; +import { SHADOW_PLACEHOLDER_ITEM_ID, SOURCES, dndzone } from "svelte-dnd-action"; +import ObsidianIcon from "./ObsidianIcon.svelte"; + +describe("svelte 5 test pipeline smoke gate", () => { + it("resolves svelte-dnd-action's load-bearing exports", () => { + expect(typeof SHADOW_PLACEHOLDER_ITEM_ID).toBe("string"); + expect(SOURCES.POINTER).toBeDefined(); + expect(typeof dndzone).toBe("function"); + }); + + it("exposes the Svelte 5 imperative mount/unmount API", () => { + expect(mount).toBeTypeOf("function"); + expect(unmount).toBeTypeOf("function"); + }); + + it("compiles + renders a component via vite-plugin-svelte using the obsidian stub", () => { + const { container } = render(ObsidianIcon, { + props: { iconId: "trash", size: 16 }, + }); + const icon = container.querySelector(".quickadd-icon"); + expect(icon).not.toBeNull(); + // onMount -> updateIcon -> setIcon(el, "trash"); the stub appends . + expect(icon?.querySelector("svg")?.getAttribute("data-icon")).toBe("trash"); + }); +}); diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts index 423f1485..10fb962c 100644 --- a/tests/obsidian-stub.ts +++ b/tests/obsidian-stub.ts @@ -637,6 +637,31 @@ export function debounce any>( return fn; } +// Standalone setIcon (used by ObsidianIcon.svelte and choiceBuilder.ts). +// Replaces the element's existing icon with a single so +// component tests can assert which icon is rendered and that it swaps reactively. +export function setIcon(parent: HTMLElement, iconId: string): void { + const existing = parent.querySelector("svg"); + if (existing) existing.remove(); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("data-icon", iconId); + parent.appendChild(svg); +} + +// Substring (NOT subsequence) matcher standing in for Obsidian's fuzzy search. +// Returns a SearchResult-like object when q is a case-insensitive substring of +// the text, else null — enough for filter tests. Do NOT assert true fuzzy +// (subsequence) semantics against this stub. +export function prepareFuzzySearch(query: string) { + const q = query.toLowerCase(); + return (text: string) => { + if (q.length === 0) return { score: 0, matches: [] as Array<[number, number]> }; + return text.toLowerCase().includes(q) + ? { score: 0, matches: [] as Array<[number, number]> } + : null; + }; +} + // Default export for compatibility export default { App, @@ -665,4 +690,6 @@ export default { moment, normalizePath, debounce, + setIcon, + prepareFuzzySearch, }; diff --git a/tests/vitest-setup.ts b/tests/vitest-setup.ts new file mode 100644 index 00000000..af7e9da9 --- /dev/null +++ b/tests/vitest-setup.ts @@ -0,0 +1,3 @@ +// Global test setup for component tests. +// Adds jest-dom matchers (toBeInTheDocument, toHaveAttribute, ...) to expect(). +import "@testing-library/jest-dom/vitest"; diff --git a/tsconfig.json b/tsconfig.json index 3fccb0cd..5ef2fd2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,9 @@ "module": "ESNext", "target": "ES2020", "allowJs": true, + "skipLibCheck": true, "noImplicitAny": true, - "moduleResolution": "bundler", + "moduleResolution": "bundler", "importHelpers": true, "isolatedModules": true, "types": ["svelte", "node"], diff --git a/vitest.config.mts b/vitest.config.mts index 95d946e2..9d3edc38 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,8 +1,10 @@ import { defineConfig } from "vitest/config"; import * as path from "path"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { svelteTesting } from "@testing-library/svelte/vite"; export default defineConfig({ - plugins: [], + plugins: [svelte(), svelteTesting()], resolve: { alias: { src: path.resolve("./src"), @@ -13,6 +15,7 @@ export default defineConfig({ include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], globals: true, environment: "jsdom", + setupFiles: ["./tests/vitest-setup.ts"], deps: { optimizer: { web: { From 5af33200d08aab046854618288c33f3fabc69b7b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 15:22:43 +0200 Subject: [PATCH 02/15] =?UTF-8?q?feat(svelte5):=20slice=202=20=E2=80=94=20?= =?UTF-8?q?shared=20mount=20harness=20+=20dnd-reorder=20utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/gui/svelte/mountComponent.ts: mount()/unmount() wrapper with an idempotent typed MountHandle (replaces new Component()/.$destroy() across hosts) - src/gui/shared/dndReorder.ts: stripShadow + replaceById pure helpers (one tested home for the SHADOW_PLACEHOLDER filtering + the immutable item replace) - pure tests: dndReorder (8) + mountComponent mount/teardown/idempotency (3) choiceListActions.ts deferred to slice 5 (signatures depend on the cluster rewrite). --- src/gui/shared/dndReorder.test.ts | 60 +++++++++++++++++++++++++++ src/gui/shared/dndReorder.ts | 29 +++++++++++++ src/gui/svelte/mountComponent.test.ts | 30 ++++++++++++++ src/gui/svelte/mountComponent.ts | 45 ++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/gui/shared/dndReorder.test.ts create mode 100644 src/gui/shared/dndReorder.ts create mode 100644 src/gui/svelte/mountComponent.test.ts create mode 100644 src/gui/svelte/mountComponent.ts diff --git a/src/gui/shared/dndReorder.test.ts b/src/gui/shared/dndReorder.test.ts new file mode 100644 index 00000000..cefec901 --- /dev/null +++ b/src/gui/shared/dndReorder.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { SHADOW_PLACEHOLDER_ITEM_ID } from "svelte-dnd-action"; +import { replaceById, stripShadow } from "./dndReorder"; + +const item = (id: string, extra: Record = {}) => ({ id, ...extra }); + +describe("stripShadow", () => { + it("removes the shadow placeholder item, preserving order", () => { + const input = [item("a"), item(SHADOW_PLACEHOLDER_ITEM_ID), item("b")]; + expect(stripShadow(input).map((i) => i.id)).toEqual(["a", "b"]); + }); + + it("preserves order AND count when no placeholder is present (no-vanish guard)", () => { + const input = [item("a"), item("b"), item("c")]; + const out = stripShadow(input); + expect(out.map((i) => i.id)).toEqual(["a", "b", "c"]); + expect(out).toHaveLength(input.length); + }); + + it("returns a NEW array without mutating the input", () => { + const input = [item("a"), item("b")]; + const out = stripShadow(input); + expect(out).not.toBe(input); + expect(input).toHaveLength(2); + }); + + it("handles an empty array", () => { + expect(stripShadow([])).toEqual([]); + }); + + it("returns empty when every item is a placeholder", () => { + const input = [item(SHADOW_PLACEHOLDER_ITEM_ID), item(SHADOW_PLACEHOLDER_ITEM_ID)]; + expect(stripShadow(input)).toEqual([]); + }); +}); + +describe("replaceById", () => { + it("replaces the matching item immutably, preserving order", () => { + const a = item("a", { v: 1 }); + const b = item("b", { v: 2 }); + const c = item("c", { v: 3 }); + const next = item("b", { v: 99 }); + const out = replaceById([a, b, c], next); + expect(out.map((i) => (i as { v?: number }).v)).toEqual([1, 99, 3]); + expect(out[1]).toBe(next); + }); + + it("returns a NEW array and does not mutate the input", () => { + const input = [item("a"), item("b")]; + const out = replaceById(input, item("a", { v: 1 })); + expect(out).not.toBe(input); + expect((input[0] as { v?: number }).v).toBeUndefined(); + }); + + it("leaves contents unchanged when no id matches", () => { + const input = [item("a"), item("b")]; + const out = replaceById(input, item("z", { v: 1 })); + expect(out.map((i) => i.id)).toEqual(["a", "b"]); + }); +}); diff --git a/src/gui/shared/dndReorder.ts b/src/gui/shared/dndReorder.ts new file mode 100644 index 00000000..db53ab2b --- /dev/null +++ b/src/gui/shared/dndReorder.ts @@ -0,0 +1,29 @@ +import { SHADOW_PLACEHOLDER_ITEM_ID } from "svelte-dnd-action"; + +/** Anything svelte-dnd-action can reorder in QuickAdd: it has a stable string id. */ +export interface Reorderable { + id: string; +} + +/** + * Remove svelte-dnd-action's internal shadow-placeholder item, returning a NEW + * array. Must be applied in BOTH the consider/finalize handlers AND the {#each} + * so a placeholder can never linger in state and vanish / leave a ghost gap on + * reorder (bugs #1244 / #883). Extracted to ONE tested helper so the three + * call-sites per list can't drift apart. + */ +export function stripShadow(items: readonly T[]): T[] { + return items.filter((item) => item.id !== SHADOW_PLACEHOLDER_ITEM_ID); +} + +/** + * Immutably replace the item whose id matches `next.id`, preserving order and + * returning a NEW array. Replaces the in-place `items[index] = next` mutation, + * which silently loses reactivity on a runes `$state`/`$bindable` array. + */ +export function replaceById( + items: readonly T[], + next: T, +): T[] { + return items.map((item) => (item.id === next.id ? next : item)); +} diff --git a/src/gui/svelte/mountComponent.test.ts b/src/gui/svelte/mountComponent.test.ts new file mode 100644 index 00000000..acfb38c0 --- /dev/null +++ b/src/gui/svelte/mountComponent.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { flushSync } from "svelte"; +import { mountComponent } from "./mountComponent"; +import ObsidianIcon from "../components/ObsidianIcon.svelte"; + +describe("mountComponent", () => { + it("mounts a component into the target and renders it", () => { + const target = document.createElement("div"); + const handle = mountComponent(target, ObsidianIcon, { iconId: "trash", size: 16 }); + expect(target.querySelector(".quickadd-icon")).not.toBeNull(); + handle.destroy(); + }); + + it("destroy() unmounts the component from the DOM", () => { + const target = document.createElement("div"); + const handle = mountComponent(target, ObsidianIcon, { iconId: "trash", size: 16 }); + expect(target.querySelector(".quickadd-icon")).not.toBeNull(); + handle.destroy(); + flushSync(); + expect(target.querySelector(".quickadd-icon")).toBeNull(); + }); + + it("destroy() is idempotent (no throw on double teardown)", () => { + const target = document.createElement("div"); + const handle = mountComponent(target, ObsidianIcon, { iconId: "trash", size: 16 }); + handle.destroy(); + flushSync(); + expect(() => handle.destroy()).not.toThrow(); + }); +}); diff --git a/src/gui/svelte/mountComponent.ts b/src/gui/svelte/mountComponent.ts new file mode 100644 index 00000000..8cff2ef8 --- /dev/null +++ b/src/gui/svelte/mountComponent.ts @@ -0,0 +1,45 @@ +import { type Component, mount, unmount } from "svelte"; + +/** + * A handle to a Svelte 5 component mounted imperatively into an Obsidian host + * (Modal contentEl, settings tab element, ...). Replaces the Svelte 4 class + * instance that hosts used to keep around (and call `.$destroy()` on). + */ +export interface MountHandle = Record> { + /** The component's exported members (Svelte 5 `mount` returns the exports, not a class instance). */ + readonly instance: Exports; + /** Unmount the component. Idempotent — safe to call from both onClose() and reload(). */ + destroy(): void; +} + +/** + * Mount a Svelte 5 component into `target` and return an idempotent handle. + * + * This is the single seam replacing `new Component({ target, props })` + + * `.$destroy()` across ChoiceBuilder, CommandSequenceEditor, the PackageManager + * modals and the settings tab. The idempotent `destroy()` guards against the + * double-teardown that arises when a Modal's `onClose()` runs after a `reload()` + * has already torn the component down. + */ +export function mountComponent< + Props extends Record, + Exports extends Record, +>( + target: HTMLElement, + component: Component, + props: Props, +): MountHandle { + const instance = mount(component, { target, props }); + let destroyed = false; + + return { + get instance() { + return instance; + }, + destroy() { + if (destroyed) return; + destroyed = true; + void unmount(instance); + }, + }; +} From e10f621f5232e7126080cf8354659d3d9130f416 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 15:25:15 +0200 Subject: [PATCH 03/15] =?UTF-8?q?feat(svelte5):=20slice=203=20=E2=80=94=20?= =?UTF-8?q?convert=20ObsidianIcon=20to=20runes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export let -> $props(); onMount + reactive $: -> a single $effect (setIcon is a DOM side-effect, so $effect not $derived); iconEl as $state for bind:this - ObsidianIcon.test.ts: mount render, size default, reactive icon swap, clean unmount Rescope vs blueprint: only ObsidianIcon is a truly standalone leaf (no events, props passed one-way), so it converts safely while parents stay legacy. FolderList moves to the templateChoiceBuilder host slice (updateFolders bridge); ChoiceItemRightButtons moves to the choiceList cluster slice (consumed via component events). --- src/gui/components/ObsidianIcon.svelte | 38 +++++++++++------------ src/gui/components/ObsidianIcon.test.ts | 40 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 21 deletions(-) create mode 100644 src/gui/components/ObsidianIcon.test.ts diff --git a/src/gui/components/ObsidianIcon.svelte b/src/gui/components/ObsidianIcon.svelte index 24a3034e..fa20fb68 100644 --- a/src/gui/components/ObsidianIcon.svelte +++ b/src/gui/components/ObsidianIcon.svelte @@ -1,27 +1,23 @@ diff --git a/src/gui/components/ObsidianIcon.test.ts b/src/gui/components/ObsidianIcon.test.ts new file mode 100644 index 00000000..57bb4863 --- /dev/null +++ b/src/gui/components/ObsidianIcon.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { flushSync } from "svelte"; +import { render } from "@testing-library/svelte"; +import ObsidianIcon from "./ObsidianIcon.svelte"; + +const iconOf = (c: HTMLElement) => + c.querySelector(".quickadd-icon svg")?.getAttribute("data-icon"); + +describe("ObsidianIcon", () => { + it("renders the icon and applies size on mount", () => { + const { container } = render(ObsidianIcon, { props: { iconId: "trash", size: 20 } }); + const svg = container.querySelector(".quickadd-icon svg"); + expect(svg?.getAttribute("data-icon")).toBe("trash"); + expect(svg?.getAttribute("width")).toBe("20"); + expect(svg?.getAttribute("height")).toBe("20"); + }); + + it("defaults size to 16", () => { + const { container } = render(ObsidianIcon, { props: { iconId: "trash" } }); + expect(container.querySelector(".quickadd-icon svg")?.getAttribute("width")).toBe("16"); + }); + + it("swaps the icon reactively when iconId changes (proves $effect, not a one-shot)", async () => { + const { container, rerender } = render(ObsidianIcon, { + props: { iconId: "trash", size: 16 }, + }); + expect(iconOf(container)).toBe("trash"); + await rerender({ iconId: "pencil", size: 16 }); + flushSync(); + expect(iconOf(container)).toBe("pencil"); + }); + + it("unmounts cleanly (teardown does not throw)", () => { + const { unmount } = render(ObsidianIcon, { props: { iconId: "trash", size: 16 } }); + expect(() => { + unmount(); + flushSync(); + }).not.toThrow(); + }); +}); From 98d080ff8529cdff12842f2a2963c230030713bd Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 15:44:05 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(svelte5):=20slice=204=20=E2=80=94=20?= =?UTF-8?q?macro=20cluster=20+=20CommandSequenceEditor=20host=20to=20runes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components (createEventDispatcher/on: -> callback props + on*; bind:-read props -> one-way): - CommandList: commands=$bindable $state; onconsider/onfinalize via shared stripShadow; updateCommand via replaceById; $state.snapshot at the saveCommands boundary; DELETE exported updateCommandList bridge + createEventDispatcher - 7 command children: $props + onDeleteCommand/onConfigure* callbacks; ConditionalCommand $: -> $derived; WaitCommand local $state(untrack) time mirror + onUpdateCommand (explicit persistence, replacing shared-ref mutation) Host (CommandSequenceEditor.ts): - new CommandList()/.$destroy()/.$on() -> mountComponent + createCommandListProps ($state props bag) + callback props; addCommand/emitCommandsChanged now immutable (callers track via onCommandsChange, verified in MacroBuilder + ConditionalBranchEditorModal) Shared: commandListProps.svelte.ts ($state bag replaces the updateCommandList bridge) Tests: macroCommands (delete/time-update/conditional routing) + CommandList (no-vanish + host-push bridge) = 5; obsidian-dataview mocked per repo convention. Verified: svelte-check 0 errors, build green, full suite 1423 pass. PENDING MANUAL QA (jsdom can't drive pointer drag): actual command drag-reorder in the dev vault (#1244 guard) — tracked for final QA. --- src/gui/MacroGUIs/CommandList.reorder.test.ts | 62 ++++ src/gui/MacroGUIs/CommandList.svelte | 330 ++++++++---------- src/gui/MacroGUIs/CommandSequenceEditor.ts | 142 ++++---- .../Components/AIAssistantCommand.svelte | 48 ++- .../Components/ConditionalCommand.svelte | 62 ++-- .../Components/NestedChoiceCommand.svelte | 48 ++- .../Components/OpenFileCommand.svelte | 45 ++- .../Components/StandardCommand.svelte | 36 +- .../Components/UserScriptCommand.svelte | 44 +-- .../MacroGUIs/Components/WaitCommand.svelte | 54 ++- .../Components/macroCommands.test.ts | 82 +++++ src/gui/MacroGUIs/commandListProps.svelte.ts | 36 ++ src/gui/svelte/mountComponent.ts | 33 +- 13 files changed, 581 insertions(+), 441 deletions(-) create mode 100644 src/gui/MacroGUIs/CommandList.reorder.test.ts create mode 100644 src/gui/MacroGUIs/Components/macroCommands.test.ts create mode 100644 src/gui/MacroGUIs/commandListProps.svelte.ts diff --git a/src/gui/MacroGUIs/CommandList.reorder.test.ts b/src/gui/MacroGUIs/CommandList.reorder.test.ts new file mode 100644 index 00000000..5bdd1518 --- /dev/null +++ b/src/gui/MacroGUIs/CommandList.reorder.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { flushSync } from "svelte"; +import { App } from "obsidian"; + +// CommandList transitively imports src/main (choice builders, macroHelpers), which +// pulls obsidian-dataview's CJS require('obsidian'); mock it as the rest of the suite does. +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { mountComponent } from "../svelte/mountComponent"; +import CommandList from "./CommandList.svelte"; +import { createCommandListProps } from "./commandListProps.svelte"; +import { WaitCommand } from "../../types/macros/QuickCommands/WaitCommand"; +import { ObsidianCommand } from "../../types/macros/ObsidianCommand"; +import type { ICommand } from "../../types/macros/ICommand"; + +const makeCommands = (): ICommand[] => [ + new ObsidianCommand("Alpha", "a"), + new WaitCommand(100), + new ObsidianCommand("Gamma", "g"), +]; + +const makeProps = (commands: ICommand[]) => + createCommandListProps({ + commands, + app: new App() as never, + plugin: {} as never, + deleteCommand: vi.fn(), + saveCommands: vi.fn(), + }); + +describe("CommandList", () => { + it("renders every command without vanishing", () => { + const target = document.createElement("div"); + document.body.appendChild(target); + + const handle = mountComponent(target, CommandList, makeProps(makeCommands())); + flushSync(); + + expect(target.querySelectorAll(".quickAddCommandListItem")).toHaveLength(3); + + handle.destroy(); + target.remove(); + }); + + it("re-renders when the host pushes a new commands array (updateCommandList bridge replacement)", () => { + const target = document.createElement("div"); + document.body.appendChild(target); + + const props = makeProps(makeCommands()); + const handle = mountComponent(target, CommandList, props); + flushSync(); + expect(target.querySelectorAll(".quickAddCommandListItem")).toHaveLength(3); + + // Host adds a command by mutating the reactive $state props bag. + props.commands = [...props.commands, new ObsidianCommand("Delta", "d")]; + flushSync(); + expect(target.querySelectorAll(".quickAddCommandListItem")).toHaveLength(4); + + handle.destroy(); + target.remove(); + }); +}); diff --git a/src/gui/MacroGUIs/CommandList.svelte b/src/gui/MacroGUIs/CommandList.svelte index 7ea4f4e1..6e787b35 100644 --- a/src/gui/MacroGUIs/CommandList.svelte +++ b/src/gui/MacroGUIs/CommandList.svelte @@ -1,50 +1,49 @@
    ) { dropTargetStyle: {}, type: "command", }} - on:consider={handleConsider} - on:finalize={handleSort} + onconsider={handleConsider} + onfinalize={handleSort} > - {#each commands.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as command (command.id)} + {#each stripShadow(commands) as command (command.id)} {#if command.type === CommandType.Wait} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onUpdateCommand={updateCommand} /> {:else if command.type === CommandType.NestedChoice} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} - on:configureChoice={configureChoice} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onConfigureChoice={configureChoice} /> {:else if command.type === CommandType.UserScript} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} - on:configureScript={configureScript} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onConfigureScript={configureScript} /> {:else if command.type === CommandType.AIAssistant} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} - on:configureAssistant={configureAssistant} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onConfigureAssistant={configureAssistant} /> {:else if command.type === CommandType.OpenFile} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} - on:configureOpenFile={configureOpenFile} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onConfigureOpenFile={configureOpenFile} /> {:else if command.type === CommandType.Conditional} await deleteCommand(e.detail)} - on:configureCondition={configureConditionalCommand} - on:editThenBranch={editConditionalThen} - on:editElseBranch={editConditionalElse} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} + onConfigureCondition={configureConditionalCommand} + onEditThenBranch={editConditionalThen} + onEditElseBranch={editConditionalElse} /> {:else} await deleteCommand(e.detail)} - on:updateCommand={updateCommandFromEvent} + {command} + {dragDisabled} + {startDrag} + onDeleteCommand={deleteCommand} /> {/if} {/each} diff --git a/src/gui/MacroGUIs/CommandSequenceEditor.ts b/src/gui/MacroGUIs/CommandSequenceEditor.ts index 059f498c..336e8664 100644 --- a/src/gui/MacroGUIs/CommandSequenceEditor.ts +++ b/src/gui/MacroGUIs/CommandSequenceEditor.ts @@ -6,6 +6,11 @@ import type { } from "obsidian"; import { ButtonComponent, Setting } from "obsidian"; import CommandList from "./CommandList.svelte"; +import { + createCommandListProps, + type CommandListProps, +} from "./commandListProps.svelte"; +import { mountComponent, type MountHandle } from "../svelte/mountComponent"; import type QuickAdd from "../../main"; import type { ICommand } from "../../types/macros/ICommand"; import type IChoice from "../../types/choices/IChoice"; @@ -70,7 +75,8 @@ export class CommandSequenceEditor { private commandsRef: ICommand[]; private obsidianCommands: IObsidianCommand[] = []; private javascriptFiles: TFile[] = []; - private commandListComponent: CommandList | null = null; + private commandListHandle: MountHandle | null = null; + private commandListProps: CommandListProps | null = null; private containerEl: HTMLElement | null = null; constructor(options: CommandSequenceEditorOptions) { @@ -100,8 +106,9 @@ export class CommandSequenceEditor { } public destroy() { - this.commandListComponent?.$destroy(); - this.commandListComponent = null; + this.commandListHandle?.destroy(); + this.commandListHandle = null; + this.commandListProps = null; } private loadObsidianCommands(): void { @@ -124,80 +131,62 @@ export class CommandSequenceEditor { private renderCommandList(parent: HTMLElement) { const commandListEl = parent.createDiv("commandList"); - this.commandListComponent = new CommandList({ - target: commandListEl, - props: { - app: this.app, - plugin: this.plugin, - commands: this.commandsRef, - deleteCommand: async (commandId: string) => { - const command = this.commandsRef.find((c) => c.id === commandId); - - if (!command) { - log.logError("command not found"); - throw new Error("command not found"); + // Wrap a conditional handler so the editor re-emits changes when the + // handler reports the command was updated (replaces the old .$on() wiring). + const wrapConditionalHandler = ( + handler?: (command: IConditionalCommand) => Promise + ) => + handler + ? async (command: IConditionalCommand) => { + const updated = await handler(command); + if (updated) { + this.emitCommandsChanged(); + } } + : undefined; + + this.commandListProps = createCommandListProps({ + app: this.app, + plugin: this.plugin, + commands: this.commandsRef, + deleteCommand: async (commandId: string) => { + const command = this.commandsRef.find((c) => c.id === commandId); + + if (!command) { + log.logError("command not found"); + throw new Error("command not found"); + } - const promptAnswer: boolean = await GenericYesNoPrompt.Prompt( - this.app, - "Are you sure you wish to delete this command?", - `If you click yes, you will delete '${command.name}'.` - ); - if (!promptAnswer) return; + const promptAnswer: boolean = await GenericYesNoPrompt.Prompt( + this.app, + "Are you sure you wish to delete this command?", + `If you click yes, you will delete '${command.name}'.` + ); + if (!promptAnswer) return; - this.commandsRef = this.commandsRef.filter( - (c) => c.id !== commandId - ); - this.emitCommandsChanged(); - }, - saveCommands: (commands: ICommand[]) => { - this.commandsRef = commands; - this.onCommandsChange?.(commands); - }, + this.commandsRef = this.commandsRef.filter((c) => c.id !== commandId); + this.emitCommandsChanged(); + }, + saveCommands: (commands: ICommand[]) => { + this.commandsRef = commands; + this.onCommandsChange?.(commands); }, + onConfigureCondition: wrapConditionalHandler( + this.conditionalHandlers?.configureCondition + ), + onEditThenBranch: wrapConditionalHandler( + this.conditionalHandlers?.editThenBranch + ), + onEditElseBranch: wrapConditionalHandler( + this.conditionalHandlers?.editElseBranch + ), }); - if (this.conditionalHandlers?.configureCondition) { - this.commandListComponent.$on( - "configureCondition", - async (event: CustomEvent) => { - const updated = await this.conditionalHandlers?.configureCondition?.( - event.detail - ); - if (updated) { - this.emitCommandsChanged(); - } - } - ); - } - - if (this.conditionalHandlers?.editThenBranch) { - this.commandListComponent.$on( - "editThenBranch", - async (event: CustomEvent) => { - const updated = await this.conditionalHandlers?.editThenBranch?.( - event.detail - ); - if (updated) { - this.emitCommandsChanged(); - } - } - ); - } - - if (this.conditionalHandlers?.editElseBranch) { - this.commandListComponent.$on( - "editElseBranch", - async (event: CustomEvent) => { - const updated = await this.conditionalHandlers?.editElseBranch?.( - event.detail - ); - if (updated) { - this.emitCommandsChanged(); - } - } - ); - } + this.commandListHandle = mountComponent( + commandListEl, + CommandList, + this.commandListProps + ); } private renderCommandBar(parent: HTMLElement) { @@ -541,14 +530,17 @@ export class CommandSequenceEditor { } private addCommand(command: ICommand) { - this.commandsRef.push(command); + // Immutable add: callers (MacroBuilder, ConditionalBranchEditorModal) track + // changes via onCommandsChange, not in-place mutation of the passed array. + this.commandsRef = [...this.commandsRef, command]; this.emitCommandsChanged(); } private emitCommandsChanged() { - if (this.commandListComponent) { - // @ts-ignore Svelte exposes exported functions on instances - this.commandListComponent.updateCommandList(this.commandsRef); + // Push the new array into the mounted component via its reactive $state props + // bag (replaces the old exported updateCommandList() bridge). + if (this.commandListProps) { + this.commandListProps.commands = [...this.commandsRef]; } this.onCommandsChange?.(this.commandsRef); } diff --git a/src/gui/MacroGUIs/Components/AIAssistantCommand.svelte b/src/gui/MacroGUIs/Components/AIAssistantCommand.svelte index ae51fa1f..971e6ed3 100644 --- a/src/gui/MacroGUIs/Components/AIAssistantCommand.svelte +++ b/src/gui/MacroGUIs/Components/AIAssistantCommand.svelte @@ -1,47 +1,47 @@
  1. {command.name}
  2. - configureAssistant()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && configureAssistant()} + onclick={() => onConfigureAssistant(command)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureAssistant(command)} class="clickable" > - deleteCommand()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand()} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > -
    - - diff --git a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte index 536b6f1c..2e6b39dd 100644 --- a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte +++ b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte @@ -1,23 +1,29 @@
    @@ -33,9 +39,9 @@ role="button" tabindex="0" class="clickable" - on:click={handleConfigure} - on:keypress={(e) => - (e.key === "Enter" || e.key === " ") && handleConfigure()} + onclick={() => onConfigureCondition(command)} + onkeypress={(e) => + (e.key === "Enter" || e.key === " ") && onConfigureCondition(command)} aria-label="Edit condition" > @@ -44,9 +50,9 @@ role="button" tabindex="0" class="clickable" - on:click={handleEditThen} - on:keypress={(e) => - (e.key === "Enter" || e.key === " ") && handleEditThen()} + onclick={() => onEditThenBranch(command)} + onkeypress={(e) => + (e.key === "Enter" || e.key === " ") && onEditThenBranch(command)} aria-label="Edit then branch" > @@ -55,9 +61,9 @@ role="button" tabindex="0" class="clickable" - on:click={handleEditElse} - on:keypress={(e) => - (e.key === "Enter" || e.key === " ") && handleEditElse()} + onclick={() => onEditElseBranch(command)} + onkeypress={(e) => + (e.key === "Enter" || e.key === " ") && onEditElseBranch(command)} aria-label="Edit else branch" > @@ -66,17 +72,17 @@ role="button" tabindex="0" class="clickable" - on:click={handleDelete} - on:keypress={(e) => - (e.key === "Enter" || e.key === " ") && handleDelete()} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => + (e.key === "Enter" || e.key === " ") && onDeleteCommand(command.id)} aria-label="Delete command" > import ObsidianIcon from "../../components/ObsidianIcon.svelte"; - import {createEventDispatcher} from "svelte"; import type {INestedChoiceCommand} from "../../../types/macros/QuickCommands/INestedChoiceCommand"; - export let command: INestedChoiceCommand; - export let startDrag: (e: MouseEvent | TouchEvent) => void; - export let dragDisabled: boolean; - const dispatch = createEventDispatcher(); - - function deleteCommand() { - dispatch('deleteCommand', command.id); - } - - function configureChoice() { - dispatch('configureChoice', command); - } + let { + command, + startDrag, + dragDisabled, + onDeleteCommand, + onConfigureChoice, + }: { + command: INestedChoiceCommand; + startDrag: (e: MouseEvent | TouchEvent) => void; + dragDisabled: boolean; + onDeleteCommand: (commandId: string) => void; + onConfigureChoice: (command: INestedChoiceCommand) => void; + } = $props();
  3. {command.name}
  4. - configureChoice()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && configureChoice()} + onclick={() => onConfigureChoice(command)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureChoice(command)} class="clickable" > - deleteCommand()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand()} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > -
    - - diff --git a/src/gui/MacroGUIs/Components/OpenFileCommand.svelte b/src/gui/MacroGUIs/Components/OpenFileCommand.svelte index 7e32cb5b..452f7160 100644 --- a/src/gui/MacroGUIs/Components/OpenFileCommand.svelte +++ b/src/gui/MacroGUIs/Components/OpenFileCommand.svelte @@ -1,48 +1,47 @@
  5. {command.name}
  6. - (e.key === 'Enter' || e.key === ' ') && configureCommand()} + onclick={() => onConfigureOpenFile(command)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureOpenFile(command)} class="clickable" > - deleteCommand(command.id)} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand(command.id)} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > - import type {ICommand} from "../../../types/macros/ICommand"; import ObsidianIcon from "../../components/ObsidianIcon.svelte"; - import {createEventDispatcher} from "svelte"; import {getCommandDisplayName} from "../../../utils/macroHelpers"; - export let command: ICommand; - export let startDrag: (e: MouseEvent | TouchEvent) => void; - export let dragDisabled: boolean; - const dispatch = createEventDispatcher(); - - function deleteCommand(commandId: string) { - dispatch('deleteCommand', commandId); - } + let { + command, + startDrag, + dragDisabled, + onDeleteCommand, + }: { + command: ICommand; + startDrag: (e: MouseEvent | TouchEvent) => void; + dragDisabled: boolean; + onDeleteCommand: (commandId: string) => void; + } = $props();
  7. {getCommandDisplayName(command)}
  8. - deleteCommand(command.id)} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand(command.id)} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > -
    - - \ No newline at end of file diff --git a/src/gui/MacroGUIs/Components/UserScriptCommand.svelte b/src/gui/MacroGUIs/Components/UserScriptCommand.svelte index b9382d6d..0b11da01 100644 --- a/src/gui/MacroGUIs/Components/UserScriptCommand.svelte +++ b/src/gui/MacroGUIs/Components/UserScriptCommand.svelte @@ -1,20 +1,20 @@
    @@ -22,28 +22,28 @@ {command.name}
    - configureChoice()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && configureChoice()} + onclick={() => onConfigureScript(command)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureScript(command)} class="clickable" > - deleteCommand()} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand()} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > - import ObsidianIcon from "../../components/ObsidianIcon.svelte"; - import {createEventDispatcher, onMount} from "svelte"; + import { onMount, untrack } from "svelte"; import type {IWaitCommand} from "../../../types/macros/QuickCommands/IWaitCommand"; - export let command: IWaitCommand; - export let startDrag: (e: MouseEvent | TouchEvent) => void; - export let dragDisabled: boolean; - const dispatch = createEventDispatcher(); + let { + command, + startDrag, + dragDisabled, + onDeleteCommand, + onUpdateCommand, + }: { + command: IWaitCommand; + startDrag: (e: MouseEvent | TouchEvent) => void; + dragDisabled: boolean; + onDeleteCommand: (commandId: string) => void; + onUpdateCommand: (command: IWaitCommand) => void; + } = $props(); - let inputEl: HTMLInputElement; - - function deleteCommand(commandId: string) { - dispatch('deleteCommand', commandId); - } + // Local mirror of command.time so the input is component-owned. We can't mutate + // the host-owned command object through the props bag, so persistence is explicit + // via onUpdateCommand (mirrors the old bind:value={command.time} on every change). + let inputEl = $state(); + // Intentionally seed once from the prop; the input is component-owned thereafter. + let time = $state(untrack(() => command.time)); function resizeInput() { - const length: number = inputEl.value.length; + if (!inputEl) return; + const length: number = String(inputEl.value).length; inputEl.style.setProperty("--qa-wait-input-width", `${length === 0 ? 2 : length}ch`); } + function onTimeInput(e: Event & { currentTarget: HTMLInputElement }) { + const next = e.currentTarget.valueAsNumber; + time = Number.isNaN(next) ? 0 : next; + resizeInput(); + onUpdateCommand({ ...command, time }); + } + onMount(resizeInput);
    -
  9. {command.name} for ms
  10. +
  11. {command.name} for ms
  12. - deleteCommand(command.id)} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteCommand(command.id)} + onclick={() => onDeleteCommand(command.id)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} class="clickable" > - getCommandDisplayName -> src/main pulls obsidian-dataview's +// CJS require('obsidian'); mock it as the rest of the suite does. +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import StandardCommand from "./StandardCommand.svelte"; +import WaitCommand from "./WaitCommand.svelte"; +import ConditionalCommand from "./ConditionalCommand.svelte"; +import { WaitCommand as WaitCommandModel } from "../../../types/macros/QuickCommands/WaitCommand"; +import { ObsidianCommand } from "../../../types/macros/ObsidianCommand"; +import { ConditionalCommand as ConditionalCommandModel } from "../../../types/macros/Conditional/ConditionalCommand"; + +const noop = () => {}; + +describe("StandardCommand", () => { + it("fires onDeleteCommand with the command id when delete is clicked", async () => { + const command = new ObsidianCommand("My Command", "obsidian-cmd"); + const onDeleteCommand = vi.fn(); + const { container } = render(StandardCommand, { + props: { command, startDrag: noop, dragDisabled: true, onDeleteCommand }, + }); + const deleteBtn = container.querySelector(".clickable") as HTMLElement; + await fireEvent.click(deleteBtn); + expect(onDeleteCommand).toHaveBeenCalledWith(command.id); + }); +}); + +describe("WaitCommand", () => { + it("fires onUpdateCommand with the edited time (persistence path)", async () => { + const command = new WaitCommandModel(100); + const onUpdateCommand = vi.fn(); + const { container } = render(WaitCommand, { + props: { + command, + startDrag: noop, + dragDisabled: true, + onDeleteCommand: noop, + onUpdateCommand, + }, + }); + const input = container.querySelector("input") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "250" } }); + expect(onUpdateCommand).toHaveBeenCalledWith( + expect.objectContaining({ id: command.id, time: 250 }), + ); + }); +}); + +describe("ConditionalCommand", () => { + it("routes each action button to the matching callback", async () => { + const command = new ConditionalCommandModel(); + const onConfigureCondition = vi.fn(); + const onEditThenBranch = vi.fn(); + const onEditElseBranch = vi.fn(); + const onDeleteCommand = vi.fn(); + const { getByLabelText } = render(ConditionalCommand, { + props: { + command, + startDrag: noop, + dragDisabled: true, + onConfigureCondition, + onEditThenBranch, + onEditElseBranch, + onDeleteCommand, + }, + }); + + await fireEvent.click(getByLabelText("Edit condition")); + expect(onConfigureCondition).toHaveBeenCalledWith(command); + + await fireEvent.click(getByLabelText("Edit then branch")); + expect(onEditThenBranch).toHaveBeenCalledWith(command); + + await fireEvent.click(getByLabelText("Edit else branch")); + expect(onEditElseBranch).toHaveBeenCalledWith(command); + + await fireEvent.click(getByLabelText("Delete command")); + expect(onDeleteCommand).toHaveBeenCalledWith(command.id); + }); +}); diff --git a/src/gui/MacroGUIs/commandListProps.svelte.ts b/src/gui/MacroGUIs/commandListProps.svelte.ts new file mode 100644 index 00000000..d8ee886a --- /dev/null +++ b/src/gui/MacroGUIs/commandListProps.svelte.ts @@ -0,0 +1,36 @@ +import type { App } from "obsidian"; +import type QuickAdd from "../../main"; +import type { ICommand } from "../../types/macros/ICommand"; +import type { IConditionalCommand } from "../../types/macros/Conditional/IConditionalCommand"; + +/** + * Props for CommandList, shared between the component and its imperative host. + * + * `commands` is the host->child channel: the host (CommandSequenceEditor) owns a + * $state-backed instance of this object (see createCommandListProps) and mutates + * `commands` to push add/delete/configure results into the mounted component — + * replacing the old exported `updateCommandList()` bridge. The child reports its + * own edits (reorder, per-command update) back up via the `saveCommands` callback. + */ +export interface CommandListProps { + commands: ICommand[]; + app: App; + plugin: QuickAdd; + deleteCommand: (commandId: string) => void | Promise; + saveCommands: (commands: ICommand[]) => void; + onConfigureCondition?: (command: IConditionalCommand) => void; + onEditThenBranch?: (command: IConditionalCommand) => void; + onEditElseBranch?: (command: IConditionalCommand) => void; +} + +/** + * Create a $state-backed props object to pass to `mount(CommandList, ...)`. + * Mutating `.commands` on the returned object re-renders the mounted component + * (the documented way to feed reactive props to an imperatively-mounted Svelte 5 + * component). Lives in a `.svelte.ts` file so `$state` is available. + */ +export function createCommandListProps(initial: CommandListProps): CommandListProps { + // $state must initialize a variable declaration (not be returned directly). + const props = $state(initial); + return props; +} diff --git a/src/gui/svelte/mountComponent.ts b/src/gui/svelte/mountComponent.ts index 8cff2ef8..3adf34cb 100644 --- a/src/gui/svelte/mountComponent.ts +++ b/src/gui/svelte/mountComponent.ts @@ -1,13 +1,11 @@ -import { type Component, mount, unmount } from "svelte"; +import { type Component, type ComponentProps, mount, unmount } from "svelte"; /** * A handle to a Svelte 5 component mounted imperatively into an Obsidian host * (Modal contentEl, settings tab element, ...). Replaces the Svelte 4 class * instance that hosts used to keep around (and call `.$destroy()` on). */ -export interface MountHandle = Record> { - /** The component's exported members (Svelte 5 `mount` returns the exports, not a class instance). */ - readonly instance: Exports; +export interface MountHandle { /** Unmount the component. Idempotent — safe to call from both onClose() and reload(). */ destroy(): void; } @@ -15,27 +13,24 @@ export interface MountHandle = Record, - Exports extends Record, ->( +export function mountComponent>( target: HTMLElement, - component: Component, - props: Props, -): MountHandle { + component: C, + props: ComponentProps, +): MountHandle { const instance = mount(component, { target, props }); let destroyed = false; return { - get instance() { - return instance; - }, destroy() { if (destroyed) return; destroyed = true; From 459b255b26d53c794c7f79a7d2061c83b53b687e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 16:02:47 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(svelte5):=20slice=205=20=E2=80=94=20?= =?UTF-8?q?recursive=20choiceList=20cluster=20to=20runes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 6 components (ChoiceView, ChoiceList, ChoiceListItem, MultiChoiceListItem, ChoiceItemRightButtons, AddChoiceBox): - createEventDispatcher + bare on: forwarding -> one shared ChoiceListActions callback bag (choiceListActions.ts), threaded by reference through the recursion (flattened payloads); MultiChoiceListItem builds nestedActions overriding onReorderChoices to write choice.choices then bubble [...roots] (calls parent's onReorderChoices, no loop) - ChoiceView: $bindable $state choices buffer; one-shot settingsStore subscribe in $effect; $state.snapshot at every saveChoices boundary; AI-button getState read kept non-reactive (behavior preserved); on:->on* ; flattened handler signatures - ChoiceList: choices=$bindable $state; stripShadow at consider/finalize/each; UNCONDITIONAL finalize re-disable preserved (NOT the macro POINTER gate); forceDragDisabled early-return - DELETED the dead bind:multiChoice landmine; REMOVED the as-any recursion casts (cycle now type-resolves); $: -> $effect for renderChoiceName; fixed self-closing span - host: quickAddSettingsTab mounts ChoiceView via mountComponent (GlobalVariablesView stays legacy new() until slice 6) Tests: ChoiceList.callbacks (flat + nested recursion wiring) + persistenceBoundary (snapshot is plain + non-aliasing). Full ChoiceView render not unit-testable (pre-existing circular imports in its graph — bundler-only); covered by manual QA. Verified: svelte-check 0 errors/0 warnings, build green, full suite 1427 pass. PENDING MANUAL QA: nested drag reorder, collapse toggle, choice CRUD + data.json persistence. --- src/gui/choiceList/AddChoiceBox.svelte | 15 +- .../choiceList/ChoiceItemRightButtons.svelte | 90 +++++----- .../choiceList/ChoiceList.callbacks.test.ts | 71 ++++++++ src/gui/choiceList/ChoiceList.svelte | 112 +++++------- src/gui/choiceList/ChoiceListItem.svelte | 82 ++++----- src/gui/choiceList/ChoiceView.svelte | 166 ++++++++---------- src/gui/choiceList/MultiChoiceListItem.svelte | 132 +++++++------- src/gui/choiceList/choiceListActions.ts | 21 +++ .../choiceList/persistenceBoundary.svelte.ts | 29 +++ .../choiceList/persistenceBoundary.test.ts | 30 ++++ src/quickAddSettingsTab.ts | 22 ++- 11 files changed, 437 insertions(+), 333 deletions(-) create mode 100644 src/gui/choiceList/ChoiceList.callbacks.test.ts create mode 100644 src/gui/choiceList/choiceListActions.ts create mode 100644 src/gui/choiceList/persistenceBoundary.svelte.ts create mode 100644 src/gui/choiceList/persistenceBoundary.test.ts diff --git a/src/gui/choiceList/AddChoiceBox.svelte b/src/gui/choiceList/AddChoiceBox.svelte index 6a5d22fe..011498f3 100644 --- a/src/gui/choiceList/AddChoiceBox.svelte +++ b/src/gui/choiceList/AddChoiceBox.svelte @@ -1,12 +1,11 @@
    @@ -29,7 +26,7 @@ - +
    \ No newline at end of file + diff --git a/src/gui/choiceList/ChoiceItemRightButtons.svelte b/src/gui/choiceList/ChoiceItemRightButtons.svelte index f4b3ab64..187f0b58 100644 --- a/src/gui/choiceList/ChoiceItemRightButtons.svelte +++ b/src/gui/choiceList/ChoiceItemRightButtons.svelte @@ -1,50 +1,50 @@
    -
    (e.key === 'Enter' || e.key === ' ') && emitToggleCommand()} + onclick={onToggleCommand} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleCommand()} class:command-enabled={commandEnabled} - class="alignIconInDivInMiddle clickable" - aria-label={`${commandEnabled ? "Disable in Command Palette" : "Enable in Command Palette"}${choiceName ? ": " + choiceName : ""}`} + class="alignIconInDivInMiddle clickable" + aria-label={`${commandEnabled ? "Disable in Command Palette" : "Enable in Command Palette"}${choiceName ? ": " + choiceName : ""}`} >
    {#if showConfigureButton} -
    (e.key === 'Enter' || e.key === ' ') && emitConfigureChoice()} - class="alignIconInDivInMiddle clickable" + onclick={onConfigureChoice} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureChoice()} + class="alignIconInDivInMiddle clickable" aria-label={`Configure${choiceName ? " " + choiceName : ""}`} > @@ -52,37 +52,37 @@ {/if} {#if showDuplicateButton} -
    (e.key === 'Enter' || e.key === ' ') && emitDuplicateChoice()} + aria-label={`Duplicate ${choiceName ?? ""}`} + class="alignIconInDivInMiddle clickable" + onclick={onDuplicateChoice} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDuplicateChoice()} >
    {/if} -
    (e.key === 'Enter' || e.key === ' ') && emitDeleteChoice()} + aria-label={`Delete${choiceName ? " " + choiceName : ""}`} + class="alignIconInDivInMiddle clickable" + onclick={onDeleteChoice} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteChoice()} >
    -
    dispatcher('dragHandleDown')} + onpointerdown={() => onDragHandleDown()} >
    @@ -107,4 +107,4 @@ .command-enabled { color: var(--text-accent); } - \ No newline at end of file + diff --git a/src/gui/choiceList/ChoiceList.callbacks.test.ts b/src/gui/choiceList/ChoiceList.callbacks.test.ts new file mode 100644 index 00000000..f0f3ee32 --- /dev/null +++ b/src/gui/choiceList/ChoiceList.callbacks.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/svelte"; + +// ChoiceListItem -> renderChoiceName/contextMenu reach src/main -> obsidian-dataview. +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { App } from "obsidian"; +import ChoiceList from "./ChoiceList.svelte"; +import type IChoice from "../../types/choices/IChoice"; +import type { ChoiceListActions } from "./choiceListActions"; + +const normal = (name: string): IChoice => + ({ id: name, name, type: "Template", command: false }) as unknown as IChoice; +const multi = (name: string, children: IChoice[]): IChoice => + ({ + id: name, + name, + type: "Multi", + command: false, + collapsed: false, + choices: children, + }) as unknown as IChoice; + +function actionsSpy(): ChoiceListActions { + return { + onDeleteChoice: vi.fn(), + onConfigureChoice: vi.fn(), + onToggleCommand: vi.fn(), + onDuplicateChoice: vi.fn(), + onRenameChoice: vi.fn(), + onMoveChoice: vi.fn(), + onReorderChoices: vi.fn(), + }; +} + +const firstArg = (fn: unknown) => (fn as { mock: { calls: unknown[][] } }).mock.calls[0][0] as { id: string }; + +describe("ChoiceList callback wiring", () => { + it("routes a flat row's buttons to the shared actions with the right choice", async () => { + const actions = actionsSpy(); + const choices = [normal("Alpha"), normal("Beta")]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + await fireEvent.click(getByLabelText("Delete Alpha")); + expect(actions.onDeleteChoice).toHaveBeenCalledTimes(1); + expect(firstArg(actions.onDeleteChoice).id).toBe("Alpha"); + + await fireEvent.click(getByLabelText("Configure Beta")); + expect(firstArg(actions.onConfigureChoice).id).toBe("Beta"); + }); + + it("threads the SAME actions through the recursion to nested choices", async () => { + const actions = actionsSpy(); + const nested = normal("Nested"); + const choices = [multi("Group", [nested])]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + // The Multi is expanded (collapsed:false), so its nested child renders. + await fireEvent.click(getByLabelText("Delete Nested")); + expect(actions.onDeleteChoice).toHaveBeenCalledTimes(1); + expect(firstArg(actions.onDeleteChoice).id).toBe("Nested"); + + // The Multi's own button targets the Multi choice. + await fireEvent.click(getByLabelText("Duplicate Group")); + expect(firstArg(actions.onDuplicateChoice).id).toBe("Group"); + }); +}); diff --git a/src/gui/choiceList/ChoiceList.svelte b/src/gui/choiceList/ChoiceList.svelte index 18b2efba..1482362c 100644 --- a/src/gui/choiceList/ChoiceList.svelte +++ b/src/gui/choiceList/ChoiceList.svelte @@ -1,100 +1,80 @@
    - {#each choices.filter(c => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as choice(choice.id)} + {#each stripShadow(choices) as choice (choice.id)} {#if choice.type !== "Multi"} {:else} {/if} {/each} diff --git a/src/gui/choiceList/ChoiceListItem.svelte b/src/gui/choiceList/ChoiceListItem.svelte index 6c592056..6b5b4a1b 100644 --- a/src/gui/choiceList/ChoiceListItem.svelte +++ b/src/gui/choiceList/ChoiceListItem.svelte @@ -1,52 +1,46 @@ @@ -57,20 +51,20 @@ tabindex="0" aria-haspopup="menu" aria-label={`Context menu for ${choice.name}`} - on:contextmenu={onContextMenu} + oncontextmenu={onContextMenu} > - + actions.onDeleteChoice(choice)} + onConfigureChoice={() => actions.onConfigureChoice(choice)} + onToggleCommand={() => actions.onToggleCommand(choice)} + onDuplicateChoice={() => actions.onDuplicateChoice(choice)} + choiceName={choice.name} + commandEnabled={choice.command} + {showConfigureButton} + {dragDisabled} showDuplicateButton={true} />
    diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte index f0678b42..bbfb1275 100644 --- a/src/gui/choiceList/ChoiceView.svelte +++ b/src/gui/choiceList/ChoiceView.svelte @@ -2,7 +2,7 @@ import type { App } from "obsidian"; import { prepareFuzzySearch } from "obsidian"; import { settingsStore } from "src/settingsStore"; - import { onMount } from "svelte"; + import { untrack } from "svelte"; import type QuickAdd from "../../main"; import { CommandRegistry, @@ -11,6 +11,7 @@ createToggleCommandChoice, deleteChoiceWithConfirmation, duplicateChoice, + moveChoice as moveChoiceService, } from "../../services/choiceService"; import type { ChoiceType } from "../../types/choices/choiceType"; import type IChoice from "../../types/choices/IChoice"; @@ -19,18 +20,40 @@ import { promptRenameChoice } from "../choiceRename"; import AddChoiceBox from "./AddChoiceBox.svelte"; import ChoiceList from "./ChoiceList.svelte"; - import { moveChoice as moveChoiceService } from "../../services/choiceService"; - - export let choices: IChoice[] = []; - export let saveChoices: (choices: IChoice[]) => void; + import type { ChoiceListActions } from "./choiceListActions"; + + let { + app, + plugin, + choices = $bindable([]), + saveChoices, + }: { + app: App; + plugin: QuickAdd; + choices?: IChoice[]; + saveChoices: (choices: IChoice[]) => void; + } = $props(); + + let filterQuery = $state(""); // not persisted + + // Command registry for managing Obsidian commands (plugin is constant for the + // component's life; untrack avoids a spurious state_referenced_locally warning). + const commandRegistry = new CommandRegistry(untrack(() => plugin)); + + // Keep choices in sync with external store changes. The subscribe callback runs + // only on store changes (not during this effect's synchronous setup), so the + // effect registers no reactive deps and subscribes exactly once. + $effect(() => { + const unsubSettingsStore = settingsStore.subscribe((settings) => { + choices = settings.choices; + }); + return () => unsubSettingsStore(); + }); - function handleReorderChoices(e: CustomEvent<{ choices: IChoice[] }>) { - saveChoices(e.detail.choices); + // Persist the current choices as a plain (non-proxy) snapshot. + function save() { + saveChoices($state.snapshot(choices) as IChoice[]); } - export let app: App; - export let plugin: QuickAdd; - - let filterQuery: string = ""; // not persisted function filterChoices(list: IChoice[], query: string): IChoice[] { const q = query.trim(); @@ -56,44 +79,23 @@ return null; }; - return list - .map((c) => walk(c)) - .filter(Boolean) as IChoice[]; + return list.map((c) => walk(c)).filter(Boolean) as IChoice[]; } - // Subscribe to settings changes to keep choices in sync - onMount(() => { - const unsubSettingsStore = settingsStore.subscribe((settings) => { - choices = settings.choices; - }); - - return () => { - unsubSettingsStore(); - }; - }); - - // Command registry for managing Obsidian commands - const commandRegistry = new CommandRegistry(plugin); - - function addChoiceToList(event: any): void { - const { name, type } = event.detail; - const newChoice = createChoice(type as ChoiceType, name); + function addChoiceToList(name: string, type: ChoiceType): void { + const newChoice = createChoice(type, name); choices = [...choices, newChoice]; - saveChoices(choices); + save(); } - async function deleteChoice(e: any) { - const choice: IChoice = e.detail.choice; - + async function deleteChoice(choice: IChoice) { const userConfirmed = await deleteChoiceWithConfirmation(choice, app); if (!userConfirmed) return; // Remove choice from array (including nested choices) - choices = choices.filter((value) => - removeChoiceHelper(choice.id, value), - ); + choices = choices.filter((value) => removeChoiceHelper(choice.id, value)); commandRegistry.disableCommand(choice); - saveChoices(choices); + save(); } function removeChoiceHelper(id: string, value: IChoice): boolean { @@ -105,23 +107,16 @@ return value.id !== id; } - async function handleConfigureChoice(e: any) { - const { choice: oldChoice } = e.detail; - + async function handleConfigureChoice(oldChoice: IChoice) { const updatedChoice = await configureChoice(oldChoice, app, plugin); if (!updatedChoice) return; - choices = choices.map((choice) => - updateChoiceHelper(choice, updatedChoice), - ); + choices = choices.map((choice) => updateChoiceHelper(choice, updatedChoice)); commandRegistry.updateCommand(oldChoice, updatedChoice); - saveChoices(choices); + save(); } - function updateChoiceHelper( - oldChoice: IChoice, - newChoice: IChoice, - ): IChoice { + function updateChoiceHelper(oldChoice: IChoice, newChoice: IChoice): IChoice { if (oldChoice.id === newChoice.id) { return { ...oldChoice, ...newChoice }; } @@ -137,47 +132,54 @@ return oldChoice; } - async function handleRenameChoice(e: any) { - const { choice } = e.detail; + async function handleRenameChoice(choice: IChoice) { if (!choice) return; const newName = await promptRenameChoice(app, choice.name); if (!newName) return; const updatedChoice = { ...choice, name: newName }; - choices = choices.map((entry) => - updateChoiceHelper(entry, updatedChoice), - ); + choices = choices.map((entry) => updateChoiceHelper(entry, updatedChoice)); commandRegistry.updateCommand(choice, updatedChoice); - saveChoices(choices); + save(); } - async function toggleCommandForChoice(e: any) { - const { choice: oldChoice } = e.detail; + function toggleCommandForChoice(oldChoice: IChoice) { const updatedChoice = createToggleCommandChoice(oldChoice); - choices = choices.map((choice) => - updateChoiceHelper(choice, updatedChoice), - ); + choices = choices.map((choice) => updateChoiceHelper(choice, updatedChoice)); updatedChoice.command ? commandRegistry.enableCommand(updatedChoice) : commandRegistry.disableCommand(updatedChoice); - saveChoices(choices); + save(); } - async function handleDuplicateChoice(e: any) { - const { choice: sourceChoice } = e.detail; + function handleDuplicateChoice(sourceChoice: IChoice) { const newChoice = duplicateChoice(sourceChoice); choices = [...choices, newChoice]; - saveChoices(choices); + save(); } - function handleMoveChoice(e: any) { - const { choice, targetId } = e.detail; + function handleMoveChoice(choice: IChoice, targetId: string) { choices = moveChoiceService(choices, choice.id, targetId); - saveChoices(choices); + save(); + } + + function handleReorderChoices(reordered: IChoice[]) { + choices = reordered; + save(); } + const actions: ChoiceListActions = { + onDeleteChoice: deleteChoice, + onConfigureChoice: handleConfigureChoice, + onToggleCommand: toggleCommandForChoice, + onDuplicateChoice: handleDuplicateChoice, + onRenameChoice: handleRenameChoice, + onMoveChoice: handleMoveChoice, + onReorderChoices: handleReorderChoices, + }; + async function openAISettings() { const newSettings = await new AIAssistantSettingsModal( app, @@ -188,7 +190,6 @@ settingsStore.setState((state) => ({ ...state, ai: newSettings })); } } - @@ -202,7 +203,7 @@ autocapitalize="off" autocorrect="off" spellcheck={false} - on:keydown={(e) => { + onkeydown={(e) => { if (e.key === 'Escape' && filterQuery) { filterQuery = ""; e.stopPropagation(); @@ -211,7 +212,7 @@ /> {#if filterQuery} @@ -221,38 +222,27 @@ {#if filterQuery.trim().length === 0} {:else} {/if}
    {#if !settingsStore.getState().disableOnlineFeatures} - {/if} - +
    diff --git a/src/gui/choiceList/MultiChoiceListItem.svelte b/src/gui/choiceList/MultiChoiceListItem.svelte index 5552d41d..31f1683d 100644 --- a/src/gui/choiceList/MultiChoiceListItem.svelte +++ b/src/gui/choiceList/MultiChoiceListItem.svelte @@ -1,66 +1,67 @@ @@ -71,14 +72,14 @@ tabindex="0" aria-haspopup="menu" aria-label={`Context menu for ${choice.name}`} - on:contextmenu={onContextMenu} + oncontextmenu={onContextMenu} > -
    choice.collapsed = !choice.collapsed} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && (choice.collapsed = !choice.collapsed)} + class="multiChoiceListItemName clickable" + onclick={toggleCollapsed} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && toggleCollapsed()} >
    actions.onDeleteChoice(choice)} + onConfigureChoice={() => actions.onConfigureChoice(choice)} + onToggleCommand={() => actions.onToggleCommand(choice)} + onDuplicateChoice={() => actions.onDuplicateChoice(choice)} + {showConfigureButton} + {dragDisabled} + choiceName={choice.name} + commandEnabled={choice.command} showDuplicateButton={true} />
    @@ -107,17 +108,10 @@ {#if !choice.collapsed}
    {/if} diff --git a/src/gui/choiceList/choiceListActions.ts b/src/gui/choiceList/choiceListActions.ts new file mode 100644 index 00000000..48ff0e82 --- /dev/null +++ b/src/gui/choiceList/choiceListActions.ts @@ -0,0 +1,21 @@ +import type IChoice from "../../types/choices/IChoice"; + +/** + * The single callback-prop bag threaded (by reference) through the recursive + * choiceList cluster: ChoiceView -> ChoiceList -> (ChoiceListItem | + * MultiChoiceListItem) -> nested ChoiceList -> ... + * + * Replaces the Svelte 4 `createEventDispatcher` + bare `on:event` forwarding. + * Payloads are flattened (the choice itself, not `{ choice }`). MultiChoiceListItem + * overrides `onReorderChoices` for its nested list so nested reorders bubble the + * whole root tree up to the top-level handler. + */ +export interface ChoiceListActions { + onDeleteChoice: (choice: IChoice) => void; + onConfigureChoice: (choice: IChoice) => void; + onToggleCommand: (choice: IChoice) => void; + onDuplicateChoice: (choice: IChoice) => void; + onRenameChoice: (choice: IChoice) => void; + onMoveChoice: (choice: IChoice, targetId: string) => void; + onReorderChoices: (choices: IChoice[]) => void; +} diff --git a/src/gui/choiceList/persistenceBoundary.svelte.ts b/src/gui/choiceList/persistenceBoundary.svelte.ts new file mode 100644 index 00000000..f6d0e358 --- /dev/null +++ b/src/gui/choiceList/persistenceBoundary.svelte.ts @@ -0,0 +1,29 @@ +import type IChoice from "../../types/choices/IChoice"; + +/** + * Isolated mirror of ChoiceView's persistence boundary: hold choices in `$state`, + * update immutably, and hand out a `$state.snapshot()` on save. Exists as a + * `.svelte.ts` module because the full ChoiceView component cannot be mounted in + * vitest (its dependency graph has pre-existing circular imports the bundler + * tolerates but vitest's ESM evaluation order does not). This lets us unit-test + * the load-bearing guarantee — snapshots are plain and do NOT alias the reactive + * source — which is what stops `$state` proxies leaking into zustand/data.json. + */ +export function createChoicesBuffer(initial: IChoice[]) { + let choices = $state(initial); + + return { + get value() { + return choices; + }, + add(choice: IChoice) { + choices = [...choices, choice]; + }, + renameFirst(name: string) { + if (choices[0]) choices[0].name = name; + }, + snapshot(): IChoice[] { + return $state.snapshot(choices) as IChoice[]; + }, + }; +} diff --git a/src/gui/choiceList/persistenceBoundary.test.ts b/src/gui/choiceList/persistenceBoundary.test.ts new file mode 100644 index 00000000..6199733b --- /dev/null +++ b/src/gui/choiceList/persistenceBoundary.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createChoicesBuffer } from "./persistenceBoundary.svelte"; +import type IChoice from "../../types/choices/IChoice"; + +const choice = (name: string): IChoice => + ({ id: name, name, type: "Template", command: false }) as unknown as IChoice; + +describe("choices persistence snapshot boundary", () => { + it("snapshot is a plain, JSON-serializable clone of the current choices", () => { + const buf = createChoicesBuffer([choice("A")]); + buf.add(choice("B")); + + const snap = buf.snapshot(); + expect(snap.map((c) => c.name)).toEqual(["A", "B"]); + // Full JSON round-trip is identity-equal -> no Proxy artifacts that would + // corrupt data.json / zustand setState. + expect(JSON.parse(JSON.stringify(snap))).toEqual(snap); + }); + + it("snapshot does NOT alias the reactive source (later mutations don't leak in)", () => { + const buf = createChoicesBuffer([choice("A")]); + const snap = buf.snapshot(); + + buf.renameFirst("Renamed"); + + // The captured snapshot is a detached clone, unaffected by later source mutation. + expect(snap[0].name).toBe("A"); + expect(buf.value[0].name).toBe("Renamed"); + }); +}); diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index c2defb77..95e6a0f8 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -10,6 +10,7 @@ import { import type QuickAdd from "./main"; import type IChoice from "./types/choices/IChoice"; import ChoiceView from "./gui/choiceList/ChoiceView.svelte"; +import { mountComponent, type MountHandle } from "./gui/svelte/mountComponent"; import { GenericTextSuggester } from "./gui/suggesters/genericTextSuggester"; import GlobalVariablesView from "./gui/GlobalVariables/GlobalVariablesView.svelte"; import { settingsStore } from "./settingsStore"; @@ -36,7 +37,7 @@ class SvelteSettingComponent extends BaseComponent { export class QuickAddSettingsTab extends PluginSettingTab { public plugin: QuickAdd; - private choiceView: ChoiceView | null = null; + private choiceViewHandle: MountHandle | null = null; private globalVariablesView: GlobalVariablesView | null = null; constructor(app: App, plugin: QuickAdd) { @@ -87,8 +88,8 @@ export class QuickAddSettingsTab extends PluginSettingTab { } private destroySettingViews(): void { - this.choiceView?.$destroy(); - this.choiceView = null; + this.choiceViewHandle?.destroy(); + this.choiceViewHandle = null; this.globalVariablesView?.$destroy(); this.globalVariablesView = null; } @@ -165,15 +166,12 @@ export class QuickAddSettingsTab extends PluginSettingTab { this.prepareFullWidthSetting(setting); const mountView = (target: HTMLElement) => { - this.choiceView = new ChoiceView({ - target, - props: { - app: this.app, - plugin: this.plugin, - choices: settingsStore.getState().choices, - saveChoices: (choices: IChoice[]) => { - settingsStore.setState({ choices }); - }, + this.choiceViewHandle = mountComponent(target, ChoiceView, { + app: this.app, + plugin: this.plugin, + choices: settingsStore.getState().choices, + saveChoices: (choices: IChoice[]) => { + settingsStore.setState({ choices }); }, }); }; From 71bde5e03206c2ed33a27b52ab46d5641ccd0cde Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 16:10:09 +0200 Subject: [PATCH 06/15] =?UTF-8?q?feat(svelte5):=20slice=206=20=E2=80=94=20?= =?UTF-8?q?remaining=20components=20+=20hosts=20to=20runes/mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components -> runes: - GlobalVariablesView: $props + items $state; onMount/onDestroy -> $effect with cleanup; preserved bind:value={it.name}/{it.value} + use:attachSuggester (source-guarded by test) - ExportPackageModal: $props + $state; 5 pure $: -> $derived (incl. let+$: rootChoiceIds merge) - ImportPackageModal: $props + $state (incl. reactive Maps); preserved {conflict.name}/{originalPath} - FolderList: $props; DELETED exported updateFolders bridge -> folderListProps.svelte.ts $state bag Hosts -> mountComponent + handle.destroy(): - ExportPackageModal.ts / ImportPackageModal.ts (onOpen/onClose) - templateChoiceBuilder.addFolderSelector (folderListProps $state bag replaces updateFolders) - choiceBuilder base: svelteElements SvelteComponent[] -> MountHandle[]; destroySvelteElements() in onClose AND at top of reload() (fixes the verified effect leak) - quickAddSettingsTab: GlobalVariablesView via mountComponent All 19 .svelte are runes; 0 export let / createEventDispatcher / on: directives remain; remaining new X({...}) are plain TS classes (CommandSequenceEditor/ConditionalBranchEditorModal/ InputPromptDraftHandler), not Svelte. Verified: svelte-check 0/0, build green, full suite 1427 pass. PENDING MANUAL QA: package import/export, global vars inline edit + debounce, folder add/remove + builder reload re-render, settings open/close mount-leak. --- src/gui/ChoiceBuilder/FolderList.svelte | 15 ++--- src/gui/ChoiceBuilder/choiceBuilder.ts | 17 ++++-- .../ChoiceBuilder/folderListProps.svelte.ts | 15 +++++ .../ChoiceBuilder/templateChoiceBuilder.ts | 28 +++++----- .../GlobalVariablesView.svelte | 28 ++++------ .../PackageManager/ExportPackageModal.svelte | 56 +++++++++++-------- src/gui/PackageManager/ExportPackageModal.ts | 22 +++----- .../PackageManager/ImportPackageModal.svelte | 41 +++++++------- src/gui/PackageManager/ImportPackageModal.ts | 16 +++--- src/quickAddSettingsTab.ts | 13 +++-- 10 files changed, 135 insertions(+), 116 deletions(-) create mode 100644 src/gui/ChoiceBuilder/folderListProps.svelte.ts diff --git a/src/gui/ChoiceBuilder/FolderList.svelte b/src/gui/ChoiceBuilder/FolderList.svelte index e8cf88d5..aa0e1daf 100644 --- a/src/gui/ChoiceBuilder/FolderList.svelte +++ b/src/gui/ChoiceBuilder/FolderList.svelte @@ -1,22 +1,19 @@
    - {#each folders as folder, i} + {#each folders as folder}
    {folder} - deleteFolder(folder)} - on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteFolder(folder)} + onclick={() => deleteFolder(folder)} + onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteFolder(folder)} class="clickable" > diff --git a/src/gui/ChoiceBuilder/choiceBuilder.ts b/src/gui/ChoiceBuilder/choiceBuilder.ts index 5b30d4f3..66afe3b6 100644 --- a/src/gui/ChoiceBuilder/choiceBuilder.ts +++ b/src/gui/ChoiceBuilder/choiceBuilder.ts @@ -1,5 +1,5 @@ import { type App, Modal, Setting, setIcon } from "obsidian"; -import type { SvelteComponent } from "svelte"; +import type { MountHandle } from "../svelte/mountComponent"; import type IChoice from "../../types/choices/IChoice"; import type { FileViewMode2, OpenLocation } from "../../types/fileOpening"; import { @@ -23,7 +23,7 @@ export abstract class ChoiceBuilder extends Modal { private resolvePromise: (input: IChoice) => void; public waitForClose: Promise; abstract choice: IChoice; - protected svelteElements: SvelteComponent[] = []; + protected svelteElements: MountHandle[] = []; protected constructor(app: App) { super(app); @@ -39,10 +39,19 @@ export abstract class ChoiceBuilder extends Modal { protected abstract display(): unknown; protected reload() { + // Unmount the previous Svelte components before re-rendering, otherwise their + // effects/subscriptions leak for the modal's lifetime (contentEl.empty() only + // removes DOM nodes, not the mounted components). + this.destroySvelteElements(); this.contentEl.empty(); this.display(); } + private destroySvelteElements() { + this.svelteElements.forEach((handle) => handle.destroy()); + this.svelteElements = []; + } + protected addOnePageOverrideSetting(choice: IChoice): void { new Setting(this.contentEl) .setName("One-page input override") @@ -213,9 +222,7 @@ export abstract class ChoiceBuilder extends Modal { onClose() { super.onClose(); - this.svelteElements.forEach((el) => { - if (el && el.$destroy) el.$destroy(); - }); + this.destroySvelteElements(); this.resolvePromise(this.choice); } } diff --git a/src/gui/ChoiceBuilder/folderListProps.svelte.ts b/src/gui/ChoiceBuilder/folderListProps.svelte.ts new file mode 100644 index 00000000..b1fe3093 --- /dev/null +++ b/src/gui/ChoiceBuilder/folderListProps.svelte.ts @@ -0,0 +1,15 @@ +/** + * Props for FolderList, shared with its imperative host (templateChoiceBuilder). + * The host owns a $state-backed instance and mutates `folders` to push add/remove + * updates into the mounted component — replacing FolderList's old exported + * `updateFolders()` bridge (which reassigned a prop, illegal under runes). + */ +export interface FolderListProps { + folders: string[]; + deleteFolder: (folder: string) => void; +} + +export function createFolderListProps(initial: FolderListProps): FolderListProps { + const props = $state(initial); + return props; +} diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts index b7be3f8a..d68af55b 100644 --- a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -30,6 +30,8 @@ import { ExclusiveSuggester } from "../suggesters/exclusiveSuggester"; import { FormatSyntaxSuggester } from "../suggesters/formatSyntaxSuggester"; import { ChoiceBuilder } from "./choiceBuilder"; import FolderList from "./FolderList.svelte"; +import { createFolderListProps } from "./folderListProps.svelte"; +import { mountComponent } from "../svelte/mountComponent"; export class TemplateChoiceBuilder extends ChoiceBuilder { choice: ITemplateChoice; @@ -238,22 +240,22 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { const folderList: HTMLDivElement = folderSelectionContainer.createDiv("folderList"); - const folderListEl = new FolderList({ - target: folderList, - props: { - folders: this.choice.folder.folders, - deleteFolder: (folder: string) => { - this.choice.folder.folders = this.choice.folder.folders.filter( - (f) => f !== folder, - ); + const folderListProps = createFolderListProps({ + folders: [...this.choice.folder.folders], + deleteFolder: (folder: string) => { + this.choice.folder.folders = this.choice.folder.folders.filter( + (f) => f !== folder, + ); - folderListEl.updateFolders(this.choice.folder.folders); - suggester.updateCurrentItems(this.choice.folder.folders); - }, + // Push the new list into the mounted component (replaces updateFolders()). + folderListProps.folders = [...this.choice.folder.folders]; + suggester.updateCurrentItems(this.choice.folder.folders); }, }); - this.svelteElements.push(folderListEl); + this.svelteElements.push( + mountComponent(folderList, FolderList, folderListProps), + ); const inputContainer = folderSelectionContainer.createDiv( "folderInputContainer", @@ -282,7 +284,7 @@ export class TemplateChoiceBuilder extends ChoiceBuilder { this.choice.folder.folders.push(input); - folderListEl.updateFolders(this.choice.folder.folders); + folderListProps.folders = [...this.choice.folder.folders]; folderInput.inputEl.value = ""; suggester.updateCurrentItems(this.choice.folder.folders); diff --git a/src/gui/GlobalVariables/GlobalVariablesView.svelte b/src/gui/GlobalVariables/GlobalVariablesView.svelte index 9a43f5f3..9f0a400d 100644 --- a/src/gui/GlobalVariables/GlobalVariablesView.svelte +++ b/src/gui/GlobalVariables/GlobalVariablesView.svelte @@ -1,17 +1,13 @@ @@ -83,7 +79,7 @@
    Global Variables
    - +
    @@ -102,18 +98,18 @@ { debouncedPersist(it); }} + oninput={() => { debouncedPersist(it); }} placeholder="Name" />
    - +
    {/each} diff --git a/src/gui/PackageManager/ExportPackageModal.svelte b/src/gui/PackageManager/ExportPackageModal.svelte index d173d890..03d70f95 100644 --- a/src/gui/PackageManager/ExportPackageModal.svelte +++ b/src/gui/PackageManager/ExportPackageModal.svelte @@ -17,10 +17,17 @@ } from "../../services/packageExportService"; import type { QuickAddPackageAssetKind } from "../../types/packages/QuickAddPackage"; - export let app: App; - export let plugin: QuickAdd; - export let allChoices: IChoice[]; - export let close: () => void; + let { + app, + plugin, + allChoices, + close, + }: { + app: App; + plugin: QuickAdd; + allChoices: IChoice[]; + close: () => void; + } = $props(); interface FlatChoice { choice: IChoice; @@ -53,20 +60,21 @@ "capture-template": "Capture template", }; - let searchQuery = ""; - let selectedChoiceIds = new Set(); - let excludedChoiceIds = new Set(); - let rootChoiceIds: string[] = []; - let outputPath = generateDefaultPackagePath(); - let exportWarnings: ExportWarnings | null = null; - let actionInProgress: "copy" | "save" | null = null; - - $: flatChoices = flattenChoicesWithPath(allChoices); - $: filteredChoices = filterFlatChoices(flatChoices, searchQuery); - $: rootChoiceIds = computeRootSelections(flatChoices, selectedChoiceIds); - $: summary = computeSummary(allChoices, rootChoiceIds, excludedChoiceIds); - $: choiceNameById = new Map( - flatChoices.map((entry) => [entry.id, entry.path.join(" / ")]), + let searchQuery = $state(""); + let selectedChoiceIds = $state(new Set()); + let excludedChoiceIds = $state(new Set()); + let outputPath = $state(generateDefaultPackagePath()); + let exportWarnings = $state(null); + let actionInProgress = $state<"copy" | "save" | null>(null); + + const flatChoices = $derived(flattenChoicesWithPath(allChoices)); + const filteredChoices = $derived(filterFlatChoices(flatChoices, searchQuery)); + const rootChoiceIds = $derived(computeRootSelections(flatChoices, selectedChoiceIds)); + const summary = $derived(computeSummary(allChoices, rootChoiceIds, excludedChoiceIds)); + const choiceNameById = $derived( + new Map( + flatChoices.map((entry) => [entry.id, entry.path.join(" / ")]), + ), ); function flattenChoicesWithPath( @@ -360,8 +368,8 @@ spellcheck={false} />
    - - + +
    @@ -376,7 +384,7 @@ toggleChoice(entry.id)} + onchange={() => toggleChoice(entry.id)} /> {entry.path.at(-1)} {#if entry.path.length > 1} @@ -456,7 +464,7 @@ diff --git a/src/gui/PackageManager/ExportPackageModal.ts b/src/gui/PackageManager/ExportPackageModal.ts index fe6e91a5..a290436b 100644 --- a/src/gui/PackageManager/ExportPackageModal.ts +++ b/src/gui/PackageManager/ExportPackageModal.ts @@ -3,9 +3,10 @@ import { Modal } from "obsidian"; import type QuickAdd from "../../main"; import type IChoice from "../../types/choices/IChoice"; import ExportPackageModalComponent from "./ExportPackageModal.svelte"; +import { mountComponent, type MountHandle } from "../svelte/mountComponent"; export class ExportPackageModal extends Modal { - private component: ExportPackageModalComponent | null = null; + private handle: MountHandle | null = null; constructor( app: App, @@ -17,21 +18,16 @@ export class ExportPackageModal extends Modal { onOpen(): void { this.modalEl.addClass("quickAddModal", "packageExportModal"); - this.component = new ExportPackageModalComponent({ - target: this.contentEl, - props: { - app: this.app, - plugin: this.plugin, - allChoices: this.choices, - close: () => this.close(), - }, + this.handle = mountComponent(this.contentEl, ExportPackageModalComponent, { + app: this.app, + plugin: this.plugin, + allChoices: this.choices, + close: () => this.close(), }); } onClose(): void { - if (this.component) { - this.component.$destroy(); - this.component = null; - } + this.handle?.destroy(); + this.handle = null; } } diff --git a/src/gui/PackageManager/ImportPackageModal.svelte b/src/gui/PackageManager/ImportPackageModal.svelte index b755aaf7..38df8874 100644 --- a/src/gui/PackageManager/ImportPackageModal.svelte +++ b/src/gui/PackageManager/ImportPackageModal.svelte @@ -16,22 +16,21 @@ parseQuickAddPackage, } from "../../services/packageImportService"; -export let app: App; -export let close: () => void; - -let loadedPackage: LoadedQuickAddPackage | null = null; -let analysis: PackageAnalysis | null = null; -let loadError: string | null = null; -let isImporting = false; -let importSummary: { +let { app, close }: { app: App; close: () => void } = $props(); + +let loadedPackage = $state(null); +let analysis = $state(null); +let loadError = $state(null); +let isImporting = $state(false); +let importSummary = $state<{ added: number; overwritten: number; skipped: number; assetsWritten: number; assetsSkipped: number; -} | null = null; +} | null>(null); -let choiceDecisions = new Map(); +let choiceDecisions = $state(new Map()); type AssetDecisionState = { mode: AssetImportMode; @@ -41,11 +40,11 @@ type AssetDecisionState = { type AssetConflict = PackageAnalysis["assetConflicts"][number]; -let assetDecisions = new Map(); -let pastedContent = ""; -let isAnalyzing = false; -let analysisToken = 0; -let hasImported = false; +let assetDecisions = $state(new Map()); +let pastedContent = $state(""); +let isAnalyzing = $state(false); +let analysisToken = $state(0); +let hasImported = $state(false); function defaultAssetDestination(conflict: AssetConflict): string { const templateFolder = settingsStore.getState().templateFolderPath?.trim(); @@ -336,7 +335,7 @@ function onAssetPathChange(conflict: AssetConflict, event: Event) { Paste package JSON @@ -393,7 +392,7 @@ function onAssetPathChange(conflict: AssetConflict, event: Event) { onAssetPathChange(conflict, event)} + oninput={(event) => onAssetPathChange(conflict, event)} placeholder="vault/path/to/file" disabled={assetState.mode === "skip"} /> @@ -453,7 +452,7 @@ function onAssetPathChange(conflict: AssetConflict, event: Event) { Action

    Missing assets:

      - {#each exportWarnings.missingAssets as asset} + {#each exportWarnings.missingAssets as asset (asset.path)}
    • {asset.path} ({assetLabels[asset.kind]})
    • {/each}
    diff --git a/src/gui/PackageManager/ImportPackageModal.svelte b/src/gui/PackageManager/ImportPackageModal.svelte index 38df8874..d161cf18 100644 --- a/src/gui/PackageManager/ImportPackageModal.svelte +++ b/src/gui/PackageManager/ImportPackageModal.svelte @@ -378,7 +378,7 @@ function onAssetPathChange(conflict: AssetConflict, event: Event) { - {#each analysis.choiceConflicts as conflict} + {#each analysis.choiceConflicts as conflict (conflict.choiceId)} {@const storedMode = choiceDecisions.get(conflict.choiceId) ?? "import"} {@const effectiveMode = @@ -416,7 +416,7 @@ function onAssetPathChange(conflict: AssetConflict, event: Event) {

    No additional assets bundled with this package.

    {:else}
    - {#each analysis.assetConflicts as conflict} + {#each analysis.assetConflicts as conflict (conflict.originalPath)} {@const defaultDestination = defaultAssetDestination(conflict)} {@const assetState = assetDecisions.get(conflict.originalPath) ?? diff --git a/src/gui/choiceList/AddChoiceBox.svelte b/src/gui/choiceList/AddChoiceBox.svelte index 011498f3..cec396df 100644 --- a/src/gui/choiceList/AddChoiceBox.svelte +++ b/src/gui/choiceList/AddChoiceBox.svelte @@ -21,10 +21,10 @@
    From 7d6fece9aad41a29b9a0613af480ae43578b776c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 16:34:09 +0200 Subject: [PATCH 08/15] fix(svelte5): persist conditional then/else branch edits (+configure-condition) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA surfaced that adding commands to a Conditional command's Then/Else branch didn't persist. Root cause: the host's commandListProps is $state, so the rendered command is a PROXY; the branch modal mutates command.thenCommands on that proxy, which does NOT write through to the host's commandsRef (Svelte 5: 'updating proxy properties does not mutate the original'). Every other edit persists via saveCommands($state.snapshot(...)), which reads the proxy's current state — but the conditional handlers uniquely persisted via emitCommandsChanged(commandsRef), passing the stale, un-mutated array. Fix: route the conditional handlers through the same snapshot path. CommandList now awaits the handler (which mutates + returns whether changed) and calls updateCommand -> saveCommands($state.snapshot(commands)). Dropped the host's stale wrapConditionalHandler/ emitCommandsChanged wiring; handlers passed directly. Also fixes the latent configure-condition persistence (same bug). Regression test: CommandList.conditional.test.ts (proxy mutation -> plain snapshot save). Verified: svelte-check 0/0, build-with-lint, full suite 1429 pass. --- .../MacroGUIs/CommandList.conditional.test.ts | 62 +++++++++++++++++++ src/gui/MacroGUIs/CommandList.svelte | 16 +++-- src/gui/MacroGUIs/CommandSequenceEditor.ts | 28 ++------- src/gui/MacroGUIs/commandListProps.svelte.ts | 8 ++- 4 files changed, 82 insertions(+), 32 deletions(-) create mode 100644 src/gui/MacroGUIs/CommandList.conditional.test.ts diff --git a/src/gui/MacroGUIs/CommandList.conditional.test.ts b/src/gui/MacroGUIs/CommandList.conditional.test.ts new file mode 100644 index 00000000..f6053751 --- /dev/null +++ b/src/gui/MacroGUIs/CommandList.conditional.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/svelte"; + +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { App } from "obsidian"; +import CommandList from "./CommandList.svelte"; +import { createCommandListProps } from "./commandListProps.svelte"; +import { ConditionalCommand } from "../../types/macros/Conditional/ConditionalCommand"; +import { WaitCommand } from "../../types/macros/QuickCommands/WaitCommand"; + +describe("CommandList conditional branch persistence", () => { + // Regression: the branch modal mutates the command, but the command rendered by + // CommandList is a $state proxy that does NOT write through to the host's + // commandsRef. CommandList must persist the mutation via saveCommands(snapshot). + it("persists then-branch edits through saveCommands as a plain snapshot", async () => { + const cond = new ConditionalCommand(); + const saveCommands = vi.fn(); + + const props = createCommandListProps({ + commands: [cond], + app: new App() as never, + plugin: {} as never, + deleteCommand: vi.fn(), + saveCommands, + // Simulate the branch editor mutating the command and reporting "changed". + onEditThenBranch: (command) => { + command.thenCommands = [new WaitCommand(100)]; + return true; + }, + }); + + const { getByLabelText } = render(CommandList, { props }); + await fireEvent.click(getByLabelText("Edit then branch")); + + await vi.waitFor(() => expect(saveCommands).toHaveBeenCalledTimes(1)); + const saved = saveCommands.mock.calls[0][0] as Array<{ thenCommands?: unknown[] }>; + expect(saved[0].thenCommands).toHaveLength(1); + // The persisted payload must be a plain snapshot (no $state Proxy artifacts). + expect(JSON.parse(JSON.stringify(saved))).toEqual(saved); + }); + + it("does NOT save when the branch handler reports no change", async () => { + const cond = new ConditionalCommand(); + const saveCommands = vi.fn(); + + const props = createCommandListProps({ + commands: [cond], + app: new App() as never, + plugin: {} as never, + deleteCommand: vi.fn(), + saveCommands, + onEditThenBranch: () => false, + }); + + const { getByLabelText } = render(CommandList, { props }); + await fireEvent.click(getByLabelText("Edit then branch")); + await Promise.resolve(); + + expect(saveCommands).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/MacroGUIs/CommandList.svelte b/src/gui/MacroGUIs/CommandList.svelte index 6e787b35..d2fa198d 100644 --- a/src/gui/MacroGUIs/CommandList.svelte +++ b/src/gui/MacroGUIs/CommandList.svelte @@ -82,16 +82,20 @@ function updateCommand(command: ICommand) { persist(); } -function configureConditionalCommand(command: IConditionalCommand) { - onConfigureCondition?.(command); +// The conditional handlers open a modal that MUTATES the passed command (its +// condition / then- / else-commands). Because `command` is a $state proxy, that +// mutation does NOT write through to the host's commandsRef — so we must persist it +// here via the same snapshot path as every other edit (updateCommand -> saveCommands). +async function configureConditionalCommand(command: IConditionalCommand) { + if (await onConfigureCondition?.(command)) updateCommand(command); } -function editConditionalThen(command: IConditionalCommand) { - onEditThenBranch?.(command); +async function editConditionalThen(command: IConditionalCommand) { + if (await onEditThenBranch?.(command)) updateCommand(command); } -function editConditionalElse(command: IConditionalCommand) { - onEditElseBranch?.(command); +async function editConditionalElse(command: IConditionalCommand) { + if (await onEditElseBranch?.(command)) updateCommand(command); } async function configureChoice(command: INestedChoiceCommand) { diff --git a/src/gui/MacroGUIs/CommandSequenceEditor.ts b/src/gui/MacroGUIs/CommandSequenceEditor.ts index 336e8664..94667bfe 100644 --- a/src/gui/MacroGUIs/CommandSequenceEditor.ts +++ b/src/gui/MacroGUIs/CommandSequenceEditor.ts @@ -131,20 +131,6 @@ export class CommandSequenceEditor { private renderCommandList(parent: HTMLElement) { const commandListEl = parent.createDiv("commandList"); - // Wrap a conditional handler so the editor re-emits changes when the - // handler reports the command was updated (replaces the old .$on() wiring). - const wrapConditionalHandler = ( - handler?: (command: IConditionalCommand) => Promise - ) => - handler - ? async (command: IConditionalCommand) => { - const updated = await handler(command); - if (updated) { - this.emitCommandsChanged(); - } - } - : undefined; - this.commandListProps = createCommandListProps({ app: this.app, plugin: this.plugin, @@ -171,15 +157,11 @@ export class CommandSequenceEditor { this.commandsRef = commands; this.onCommandsChange?.(commands); }, - onConfigureCondition: wrapConditionalHandler( - this.conditionalHandlers?.configureCondition - ), - onEditThenBranch: wrapConditionalHandler( - this.conditionalHandlers?.editThenBranch - ), - onEditElseBranch: wrapConditionalHandler( - this.conditionalHandlers?.editElseBranch - ), + // Handlers mutate the command and return whether it changed; CommandList + // persists the (proxy) mutation via its snapshot path. + onConfigureCondition: this.conditionalHandlers?.configureCondition, + onEditThenBranch: this.conditionalHandlers?.editThenBranch, + onEditElseBranch: this.conditionalHandlers?.editElseBranch, }); this.commandListHandle = mountComponent( diff --git a/src/gui/MacroGUIs/commandListProps.svelte.ts b/src/gui/MacroGUIs/commandListProps.svelte.ts index d8ee886a..fab84ec2 100644 --- a/src/gui/MacroGUIs/commandListProps.svelte.ts +++ b/src/gui/MacroGUIs/commandListProps.svelte.ts @@ -18,9 +18,11 @@ export interface CommandListProps { plugin: QuickAdd; deleteCommand: (commandId: string) => void | Promise; saveCommands: (commands: ICommand[]) => void; - onConfigureCondition?: (command: IConditionalCommand) => void; - onEditThenBranch?: (command: IConditionalCommand) => void; - onEditElseBranch?: (command: IConditionalCommand) => void; + // Return true when the command was changed so CommandList persists it (the + // handler mutates the command; CommandList snapshots+saves — see CommandList.svelte). + onConfigureCondition?: (command: IConditionalCommand) => boolean | Promise; + onEditThenBranch?: (command: IConditionalCommand) => boolean | Promise; + onEditElseBranch?: (command: IConditionalCommand) => boolean | Promise; } /** From 3bd4a5aa1314b269e2b616a869a6a75436f3cb82 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 17:01:09 +0200 Subject: [PATCH 09/15] test(e2e): real-GUI regression test for conditional Then-branch persistence Drives the live macro editor through obsidian-e2e (configure macro -> Edit then branch -> Add wait command -> Save -> close) and asserts the added command lands in data.json (thenCommands.length === 1). Guards the runes-rewrite fix end-to-end. Verified PASSING against the live dev vault; complements the component-level guard in CommandList.conditional.test.ts (which fails on the pre-fix code). --- .../conditional-branch-persistence.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/e2e/conditional-branch-persistence.test.ts diff --git a/tests/e2e/conditional-branch-persistence.test.ts b/tests/e2e/conditional-branch-persistence.test.ts new file mode 100644 index 00000000..4cb86f2c --- /dev/null +++ b/tests/e2e/conditional-branch-persistence.test.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + acquireVaultRunLock, + clearVaultRunLockMarker, + createObsidianClient, +} from "obsidian-e2e"; +import type { ObsidianClient, PluginHandle, VaultRunLock } from "obsidian-e2e"; + +const VAULT = "dev"; +const PLUGIN_ID = "quickadd"; +const CHOICE_ID = "qa-e2e-cond-branch"; +const COND_ID = "qa-e2e-cond"; + +let obsidian: ObsidianClient; +let qa: PluginHandle; +let lock: VaultRunLock | undefined; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const HELP = `const allBy=(l)=>Array.from(document.querySelectorAll('[aria-label="'+l+'"]')); const lastBy=(l)=>{const e=allBy(l);return e[e.length-1]||null;}; const byText=(t)=>Array.from(document.querySelectorAll('.modal-container button')).filter(b=>b.textContent.trim()===t);`; +// Fast, synchronous evals only: dev.eval does not await long async bodies, but a +// synchronous click + immediate return transmits reliably. Timing is orchestrated +// here in Node (sleep between steps) so the app's async work settles. +const sev = (body: string) => + obsidian.dev.eval( + `(() => { ${HELP} try { ${body} } catch(e){ return 'ERR '+String(e&&e.message||e); } })()`, + ); + +async function closeAllModals() { + for (let i = 0; i < 6; i++) { + await sev( + `document.querySelectorAll('.modal-close-button').forEach(b=>b.click()); try{app.setting.close()}catch{} return '';`, + ); + await sleep(150); + } +} + +beforeAll(async () => { + obsidian = createObsidianClient({ vault: VAULT }); + await obsidian.verify(); + lock = await acquireVaultRunLock({ + vaultName: VAULT, + vaultPath: await obsidian.vaultPath(), + }); + await lock.publishMarker(obsidian); + qa = obsidian.plugin(PLUGIN_ID); + await qa.reload({ waitUntilReady: true }); +}, 30_000); + +afterAll(async () => { + try { await closeAllModals(); } catch { /* ignore */ } + try { await qa?.restoreData?.(); } catch { /* ignore */ } + try { await qa?.reload?.(); } catch { /* ignore */ } + try { if (obsidian) await clearVaultRunLockMarker(obsidian); } catch { /* ignore */ } + try { await lock?.release(); } catch { /* ignore */ } +}, 20_000); + +describe("conditional command branch persistence (regression for the runes rewrite)", () => { + it("persists a command added to a Conditional's Then branch through the macro editor GUI", async () => { + // Seed a macro choice with a single Conditional command (empty then/else). + await qa.data<{ choices: Record[] }>().patch((data) => { + data.choices = (data.choices ?? []).filter((c) => c.id !== CHOICE_ID); + data.choices.push({ + id: CHOICE_ID, + name: "QA-E2E Conditional", + type: "Macro", + command: false, + runOnStartup: false, + macro: { + id: `${CHOICE_ID}-macro`, + name: "QA-E2E Conditional", + commands: [ + { + id: COND_ID, + name: "If condition", + type: "Conditional", + condition: { mode: "variable", variableName: "", operator: "isTruthy", valueType: "string" }, + thenCommands: [], + elseCommands: [], + }, + ], + }, + }); + }); + await qa.reload({ waitUntilReady: true }); + await closeAllModals(); + + // Drive the real settings GUI: configure the macro -> edit Then branch -> + // add a Wait command -> Save -> close the macro builder. + await sev(`app.setting.open(); return '';`); + await sleep(600); + await sev(`app.setting.openTabById('${PLUGIN_ID}'); return '';`); + await sleep(1500); + + const cfg = await sev(`const c=lastBy('Configure QA-E2E Conditional'); c&&c.click(); return 'cfg='+!!c;`); + expect(cfg).toBe("cfg=true"); + await sleep(1500); + + const then = await sev(`const t=lastBy('Edit then branch'); t&&t.click(); return 'then='+!!t;`); + expect(then).toBe("then=true"); + await sleep(1500); + + const wait = await sev(`const w=lastBy('Add wait command'); w&&w.click(); return 'wait='+!!w;`); + expect(wait).toBe("wait=true"); + await sleep(800); + + const saved = await sev(`const s=byText('Save'); s.length&&s[s.length-1].click(); return 'save='+s.length;`); + expect(saved).toBe("save=1"); + await sleep(1200); + + await sev(`const x=Array.from(document.querySelectorAll('.modal-container .modal-close-button')); x.length&&x[x.length-1].click(); return '';`); + await sleep(1200); + await sev(`try{app.setting.close()}catch{} return '';`); + await sleep(500); + + // Assert the added command persisted to data.json on disk. + const onDiskThen = await obsidian.dev.eval(`(async () => { + const p=app.plugins.plugins.quickadd; + const raw=await p.app.vault.adapter.read(p.manifest.dir+'/data.json'); + const ch=JSON.parse(raw).choices.find(c=>c.id==='${CHOICE_ID}'); + const cond=ch&&ch.macro.commands.find(c=>c.id==='${COND_ID}'); + return cond ? cond.thenCommands.length : -1; + })()`); + + expect(onDiskThen).toBe(1); + }, 60_000); +}); From c79fb5bfdd5d7a7ce4b4d706a878b01afe4c9d2e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 20:52:34 +0200 Subject: [PATCH 10/15] docs(svelte5): correct stale smoke-test comments after ObsidianIcon runes conversion Addresses CodeRabbit review on PR #1248: ObsidianIcon now uses $effect (no onMount/ updateIcon), so the smoke test's flow comment (line 40) and the file docstring (calling ObsidianIcon a 'legacy' component) were stale. Comment-only; tests unchanged. --- .claude/scheduled_tasks.lock | 1 + src/gui/components/svelte5-pipeline.smoke.test.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..c442d1d3 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"48346aee-020f-4be7-99a3-7a5459de0b09","pid":72436,"procStart":"Fri May 29 11:59:02 2026","acquiredAt":1780080049130} \ No newline at end of file diff --git a/src/gui/components/svelte5-pipeline.smoke.test.ts b/src/gui/components/svelte5-pipeline.smoke.test.ts index 9446c7f6..6351eff7 100644 --- a/src/gui/components/svelte5-pipeline.smoke.test.ts +++ b/src/gui/components/svelte5-pipeline.smoke.test.ts @@ -9,9 +9,8 @@ * conditions (svelteTesting adds the 'browser' condition; the lib also exposes a * 'svelte' source condition — confirm we don't pull a broken build) * - * ObsidianIcon is still a legacy (Svelte 4 syntax) component here; that it renders - * confirms legacy + runes components coexist in one compile, which the atomic rewrite - * relies on while clusters are converted. + * ObsidianIcon is now a runes component ($props + $effect); that it renders confirms + * the test pipeline compiles and mounts runes components against the obsidian stub. */ import { describe, expect, it } from "vitest"; import { render } from "@testing-library/svelte"; @@ -37,7 +36,7 @@ describe("svelte 5 test pipeline smoke gate", () => { }); const icon = container.querySelector(".quickadd-icon"); expect(icon).not.toBeNull(); - // onMount -> updateIcon -> setIcon(el, "trash"); the stub appends . + // $effect -> setIcon(el, "trash"); the stub appends . expect(icon?.querySelector("svg")?.getAttribute("data-icon")).toBe("trash"); }); }); From 08d63047351b9c5328c6bd6e0ab3f5fde26162ab Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 21:12:06 +0200 Subject: [PATCH 11/15] chore(svelte5): add minimal svelte.config.js for check/test/lint tooling vitePreprocess config consumed by svelte-check, vite-plugin-svelte (tests) and eslint-plugin-svelte; silences the 'no Svelte config found' notice. The production build is unaffected (esbuild-svelte reads its own options from esbuild.config.mjs). Verified: svelte-check 0/0, build green, full suite 1429, lint green. --- svelte.config.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 svelte.config.mjs diff --git a/svelte.config.mjs b/svelte.config.mjs new file mode 100644 index 00000000..a2f9ddf2 --- /dev/null +++ b/svelte.config.mjs @@ -0,0 +1,12 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +// Shared Svelte config for the tooling that looks it up: svelte-check, the +// vite-plugin-svelte test pipeline, and eslint-plugin-svelte. It also silences +// vite-plugin-svelte's "no Svelte config found" notice during tests. +// +// NOTE: the PRODUCTION build does NOT read this file — it uses esbuild-svelte with +// its own compilerOptions ({ css: 'injected' }) + svelte-preprocess in +// esbuild.config.mjs. Keep this minimal so the two paths stay equivalent. +export default { + preprocess: vitePreprocess(), +}; From 47c1a0cddc87cab8aaf8547ce9fb3147d5d73f15 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 21:14:03 +0200 Subject: [PATCH 12/15] refactor(svelte5): replace as-any casts in ChoiceView with an isMultiChoice guard removeChoiceHelper/updateChoiceHelper/filterChoices now narrow via isMultiChoice(c): c is IMultiChoice instead of (value as any).choices. Multi-node clones build a typed IMultiChoice local (no cast). Verified: svelte-check 0/0, choiceList tests pass, build green. --- src/gui/choiceList/ChoiceView.svelte | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte index bbfb1275..51941780 100644 --- a/src/gui/choiceList/ChoiceView.svelte +++ b/src/gui/choiceList/ChoiceView.svelte @@ -15,6 +15,7 @@ } from "../../services/choiceService"; import type { ChoiceType } from "../../types/choices/choiceType"; import type IChoice from "../../types/choices/IChoice"; + import type IMultiChoice from "../../types/choices/IMultiChoice"; import { AIAssistantSettingsModal } from "../AIAssistantSettingsModal"; import ObsidianIcon from "../components/ObsidianIcon.svelte"; import { promptRenameChoice } from "../choiceRename"; @@ -55,6 +56,8 @@ saveChoices($state.snapshot(choices) as IChoice[]); } + const isMultiChoice = (c: IChoice): c is IMultiChoice => c.type === "Multi"; + function filterChoices(list: IChoice[], query: string): IChoice[] { const q = query.trim(); if (!q) return list; @@ -62,18 +65,22 @@ const walk = (c: IChoice): IChoice | null => { const selfMatches = !!match(c.name ?? ""); - if (c.type !== "Multi") { + if (!isMultiChoice(c)) { return selfMatches ? c : null; } - const mc = c as any; // IMultiChoice - const filteredChildren = (mc.choices ?? []) - .map((child: IChoice) => walk(child)) + const filteredChildren = (c.choices ?? []) + .map((child) => walk(child)) .filter(Boolean) as IChoice[]; if (selfMatches || filteredChildren.length > 0) { // Clone Multi node expanded with only matching children to avoid mutating original - return { ...mc, collapsed: false, choices: filteredChildren } as IChoice; + const expanded: IMultiChoice = { + ...c, + collapsed: false, + choices: filteredChildren, + }; + return expanded; } return null; @@ -99,10 +106,8 @@ } function removeChoiceHelper(id: string, value: IChoice): boolean { - if (value.type === "Multi") { - (value as any).choices = (value as any).choices.filter((v: any) => - removeChoiceHelper(id, v), - ); + if (isMultiChoice(value)) { + value.choices = value.choices.filter((v) => removeChoiceHelper(id, v)); } return value.id !== id; } @@ -121,12 +126,12 @@ return { ...oldChoice, ...newChoice }; } - if (oldChoice.type === "Multi") { - const multiChoice = oldChoice as any; - const updatedChoices = multiChoice.choices.map((c: any) => + if (isMultiChoice(oldChoice)) { + const updatedChoices = oldChoice.choices.map((c) => updateChoiceHelper(c, newChoice), ); - return { ...multiChoice, choices: updatedChoices }; + const updated: IMultiChoice = { ...oldChoice, choices: updatedChoices }; + return updated; } return oldChoice; From dc940f7d93d2c524ada428338ec608875f1055ca Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 21:16:39 +0200 Subject: [PATCH 13/15] refactor(svelte5): compile-time guard for the $state.snapshot persistence boundary Adds Plain branded type + snapshot() helper (persist.svelte.ts). The saveChoices / saveCommands sinks now accept only Plain<...>, so handing a live $state proxy to persistence is a svelte-check ERROR rather than a silent data-loss bug (the class behind the conditional Then/Else regression). Verified the brand bites (raw proxy -> 'Property [PLAIN] is missing') and that all gates stay green: svelte-check 0/0, build, full suite. --- src/gui/MacroGUIs/CommandList.svelte | 3 ++- src/gui/MacroGUIs/commandListProps.svelte.ts | 5 +++- src/gui/choiceList/ChoiceView.svelte | 6 +++-- src/gui/svelte/persist.svelte.ts | 26 ++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 src/gui/svelte/persist.svelte.ts diff --git a/src/gui/MacroGUIs/CommandList.svelte b/src/gui/MacroGUIs/CommandList.svelte index d2fa198d..3a405307 100644 --- a/src/gui/MacroGUIs/CommandList.svelte +++ b/src/gui/MacroGUIs/CommandList.svelte @@ -2,6 +2,7 @@ import type { ICommand } from "../../types/macros/ICommand"; import { type DndEvent, dndzone, SOURCES } from "svelte-dnd-action"; import { replaceById, stripShadow } from "../shared/dndReorder"; +import { snapshot } from "../svelte/persist.svelte"; import type { CommandListProps } from "./commandListProps.svelte"; import StandardCommand from "./Components/StandardCommand.svelte"; import { CommandType } from "../../types/macros/CommandType"; @@ -53,7 +54,7 @@ const asConditional = (c: ICommand) => c as IConditionalCommand; /** Persist the current order/content to the host (plain, non-proxy snapshot). */ function persist() { - saveCommands($state.snapshot(commands) as ICommand[]); + saveCommands(snapshot(commands)); } function handleConsider(e: CustomEvent) { diff --git a/src/gui/MacroGUIs/commandListProps.svelte.ts b/src/gui/MacroGUIs/commandListProps.svelte.ts index fab84ec2..c3e1d37a 100644 --- a/src/gui/MacroGUIs/commandListProps.svelte.ts +++ b/src/gui/MacroGUIs/commandListProps.svelte.ts @@ -2,6 +2,7 @@ import type { App } from "obsidian"; import type QuickAdd from "../../main"; import type { ICommand } from "../../types/macros/ICommand"; import type { IConditionalCommand } from "../../types/macros/Conditional/IConditionalCommand"; +import type { Plain } from "../svelte/persist.svelte"; /** * Props for CommandList, shared between the component and its imperative host. @@ -17,7 +18,9 @@ export interface CommandListProps { app: App; plugin: QuickAdd; deleteCommand: (commandId: string) => void | Promise; - saveCommands: (commands: ICommand[]) => void; + // Accepts only Plain (from snapshot()) so a live $state proxy can't + // be persisted without snapshotting — see persist.svelte.ts. + saveCommands: (commands: Plain) => void; // Return true when the command was changed so CommandList persists it (the // handler mutates the command; CommandList snapshots+saves — see CommandList.svelte). onConfigureCondition?: (command: IConditionalCommand) => boolean | Promise; diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte index 51941780..5720eeb8 100644 --- a/src/gui/choiceList/ChoiceView.svelte +++ b/src/gui/choiceList/ChoiceView.svelte @@ -22,6 +22,7 @@ import AddChoiceBox from "./AddChoiceBox.svelte"; import ChoiceList from "./ChoiceList.svelte"; import type { ChoiceListActions } from "./choiceListActions"; + import { type Plain, snapshot } from "../svelte/persist.svelte"; let { app, @@ -32,7 +33,8 @@ app: App; plugin: QuickAdd; choices?: IChoice[]; - saveChoices: (choices: IChoice[]) => void; + // Accepts only Plain (from snapshot()) — see persist.svelte.ts. + saveChoices: (choices: Plain) => void; } = $props(); let filterQuery = $state(""); // not persisted @@ -53,7 +55,7 @@ // Persist the current choices as a plain (non-proxy) snapshot. function save() { - saveChoices($state.snapshot(choices) as IChoice[]); + saveChoices(snapshot(choices)); } const isMultiChoice = (c: IChoice): c is IMultiChoice => c.type === "Multi"; diff --git a/src/gui/svelte/persist.svelte.ts b/src/gui/svelte/persist.svelte.ts new file mode 100644 index 00000000..3ac5a25f --- /dev/null +++ b/src/gui/svelte/persist.svelte.ts @@ -0,0 +1,26 @@ +declare const PLAIN: unique symbol; + +/** + * A value detached from Svelte's reactive `$state` proxy graph via {@link snapshot}, + * making it safe to hand to a persistence sink (zustand `setState`, Obsidian + * `saveData`, `JSON.stringify`, `structuredClone`, ...). + * + * THE RULE (enforced, not by convention): + * Never pass a live `$state` proxy to something that persists or serializes it. + * Reactive values must cross that boundary through {@link snapshot}, which both + * deep-clones AND brands the result `Plain`. Persistence callbacks + * (e.g. `saveChoices`, `saveCommands`) accept only `Plain<...>`, so a forgotten + * snapshot is a COMPILE error rather than a silent data-loss bug. + * + * Why this exists: a `$state` proxy mutation does not write through to the original + * object, so persisting the un-snapshotted source silently drops in-component edits. + * That is exactly the conditional Then/Else-branch regression this guard prevents + * from recurring. `Plain` is assignable to `T`, so plain data flows onward freely; + * only the inbound direction (raw proxy -> sink) is blocked. + */ +export type Plain = T & { readonly [PLAIN]: true }; + +/** Deep-clone a (possibly reactive) value into a plain, persistence-safe snapshot. */ +export function snapshot(value: T): Plain { + return $state.snapshot(value) as unknown as Plain; +} From 8d61ac8390af01fe3ea13d50b68fce6fa5511bc9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 21:20:17 +0200 Subject: [PATCH 14/15] fix(svelte5): unload markdown-render Component on destroy in choice rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChoiceListItem + MultiChoiceListItem pass cmp (new Component()) to MarkdownRenderer.render as the lifecycle owner but never unloaded it, leaking any registered child components (pre-existing on master; addresses CodeRabbit review on #1248). Added a no-dependency $effect whose teardown calls cmp.unload() — disposes on destroy only, preserving visible behavior. Test: cmpLifecycle.test.ts asserts unload() fires on unmount for both rows. Verified: svelte-check 0/0, build green, full suite. --- src/gui/choiceList/ChoiceListItem.svelte | 7 +++ src/gui/choiceList/MultiChoiceListItem.svelte | 7 +++ src/gui/choiceList/cmpLifecycle.test.ts | 57 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 src/gui/choiceList/cmpLifecycle.test.ts diff --git a/src/gui/choiceList/ChoiceListItem.svelte b/src/gui/choiceList/ChoiceListItem.svelte index 6b5b4a1b..aa30d9b6 100644 --- a/src/gui/choiceList/ChoiceListItem.svelte +++ b/src/gui/choiceList/ChoiceListItem.svelte @@ -33,6 +33,13 @@ } }); + // renderChoiceName passes cmp to MarkdownRenderer.render as the lifecycle owner; + // unload it on destroy so any registered child components are disposed (no deps + // here, so the teardown runs only when this row is destroyed). + $effect(() => { + return () => cmp.unload(); + }); + function onContextMenu(evt: MouseEvent) { showChoiceContextMenu(app, evt, choice, roots, { onRename: () => actions.onRenameChoice(choice), diff --git a/src/gui/choiceList/MultiChoiceListItem.svelte b/src/gui/choiceList/MultiChoiceListItem.svelte index 31f1683d..2fb1501e 100644 --- a/src/gui/choiceList/MultiChoiceListItem.svelte +++ b/src/gui/choiceList/MultiChoiceListItem.svelte @@ -38,6 +38,13 @@ } }); + // renderChoiceName passes cmp to MarkdownRenderer.render as the lifecycle owner; + // unload it on destroy so any registered child components are disposed (no deps + // here, so the teardown runs only when this item is destroyed). + $effect(() => { + return () => cmp.unload(); + }); + function onContextMenu(evt: MouseEvent) { showChoiceContextMenu(app, evt, choice, roots, { onRename: () => actions.onRenameChoice(choice), diff --git a/src/gui/choiceList/cmpLifecycle.test.ts b/src/gui/choiceList/cmpLifecycle.test.ts new file mode 100644 index 00000000..eeb5159b --- /dev/null +++ b/src/gui/choiceList/cmpLifecycle.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { App, Component } from "obsidian"; +import { render } from "@testing-library/svelte"; +import { flushSync } from "svelte"; +import ChoiceListItem from "./ChoiceListItem.svelte"; +import MultiChoiceListItem from "./MultiChoiceListItem.svelte"; +import type IChoice from "../../types/choices/IChoice"; +import type IMultiChoice from "../../types/choices/IMultiChoice"; +import type { ChoiceListActions } from "./choiceListActions"; + +const actions = (): ChoiceListActions => ({ + onDeleteChoice: vi.fn(), + onConfigureChoice: vi.fn(), + onToggleCommand: vi.fn(), + onDuplicateChoice: vi.fn(), + onRenameChoice: vi.fn(), + onMoveChoice: vi.fn(), + onReorderChoices: vi.fn(), +}); +const noop = () => {}; + +describe("choice row markdown-Component lifecycle", () => { + it("ChoiceListItem unloads its render Component on destroy", () => { + const spy = vi.spyOn(Component.prototype, "unload"); + const before = spy.mock.calls.length; + const choice = { id: "a", name: "Alpha", type: "Template", command: false } as unknown as IChoice; + + const { unmount } = render(ChoiceListItem, { + props: { choice, app: new App() as never, roots: [choice], dragDisabled: true, startDrag: noop, actions: actions() }, + }); + unmount(); + flushSync(); + + expect(spy.mock.calls.length).toBeGreaterThan(before); + spy.mockRestore(); + }); + + it("MultiChoiceListItem unloads its render Component on destroy", () => { + const spy = vi.spyOn(Component.prototype, "unload"); + const before = spy.mock.calls.length; + const choice = { + id: "g", name: "Group", type: "Multi", command: false, collapsed: true, choices: [], + } as unknown as IMultiChoice; + + const { unmount } = render(MultiChoiceListItem, { + props: { choice, roots: [choice], collapseId: "", dragDisabled: true, startDrag: noop, app: new App() as never, actions: actions() }, + }); + unmount(); + flushSync(); + + expect(spy.mock.calls.length).toBeGreaterThan(before); + spy.mockRestore(); + }); +}); From 7773f15b7c8a21d2ba77c65d24e100ad7163e589 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 29 May 2026 22:11:35 +0200 Subject: [PATCH 15/15] chore: stop tracking Claude Code runtime state (.claude/scheduled_tasks.lock) The ephemeral scheduler lock (sessionId/pid) was accidentally swept into c79fb5b by a git add -A. Untrack it and gitignore .claude/scheduled_tasks.{lock,json} (machine-local runtime state). File kept on disk. --- .claude/scheduled_tasks.lock | 1 - .gitignore | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index c442d1d3..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"48346aee-020f-4be7-99a3-7a5459de0b09","pid":72436,"procStart":"Fri May 29 11:59:02 2026","acquiredAt":1780080049130} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4b959062..d7897f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ data.json src/**/*.js # e2e test failure artifacts -.obsidian-e2e-artifacts -# Test coverage output -coverage/ +.obsidian-e2e-artifacts +# Test coverage output +coverage/ + +# Claude Code session/runtime state (ephemeral, machine-local) +.claude/scheduled_tasks.lock +.claude/scheduled_tasks.json