diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..96fa800 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,43 @@ +name: Main + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6.0.3 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Test + run: bun run test + + - name: Build + run: bun run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..88408ce --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,92 @@ +name: Publish Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag to create, for example v0.1.0. Defaults to manifest version." + required: false + type: string + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish: + name: Build and publish release + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6.0.3 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Test + run: bun run test + + - name: Build + run: bun run build + + - name: Prepare release metadata + id: release + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + VERSION="$(node -p "require('./manifest.json').version")" + TAG_NAME="${GITHUB_REF_NAME}" + + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + TAG_NAME="${INPUT_TAG}" + if [ -z "${TAG_NAME}" ]; then + TAG_NAME="v${VERSION}" + fi + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "tag_name=${TAG_NAME}" >> "${GITHUB_OUTPUT}" + echo "zip_name=settings-float-${VERSION}.zip" >> "${GITHUB_OUTPUT}" + + - name: Package release zip + run: | + zip "${{ steps.release.outputs.zip_name }}" manifest.json main.js styles.css + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NOTES_FILE="docs/release-notes/${{ steps.release.outputs.version }}.md" + NOTES_ARGS=() + + if [ -f "${NOTES_FILE}" ]; then + NOTES_ARGS=(--notes-file "${NOTES_FILE}") + else + NOTES_ARGS=(--generate-notes) + fi + + gh release create "${{ steps.release.outputs.tag_name }}" \ + "${{ steps.release.outputs.zip_name }}" \ + manifest.json \ + main.js \ + styles.css \ + --title "Settings Float ${{ steps.release.outputs.version }}" \ + --target "${GITHUB_SHA}" \ + "${NOTES_ARGS[@]}" diff --git a/.gitignore b/.gitignore index 872d5f6..5f12d90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,143 +1,21 @@ -# Logs -logs -*.log +node_modules/ +main.js +*.tsbuildinfo +coverage/ +.obsidian/ +.vault-test/ +docs/ideation/ +.DS_Store +.idea/ +.vscode/ npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* +pnpm-debug.log* -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Obsidian plugin build artifact +main.js -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# Obsidian local config +data.json -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist -.output - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp directory -.temp - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# pnpm -.pnpm-store - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -.vite/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa6abd --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Settings Float + +[![Obsidian](https://img.shields.io/badge/Obsidian-1.6.0%2B-7C3AED?logo=obsidian&logoColor=white)](https://obsidian.md) +[![Platform](https://img.shields.io/badge/platform-desktop-4B5563)](#compatibility-notes) +[![Language](https://img.shields.io/badge/language-TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Package manager](https://img.shields.io/badge/package_manager-bun_1.3.14-000000?logo=bun&logoColor=white)](https://bun.sh/) +[![CI](https://github.com/swartzrock/obsidian-settings-float-plugin/actions/workflows/main.yml/badge.svg)](https://github.com/swartzrock/obsidian-settings-float-plugin/actions/workflows/main.yml) +[![GitHub release](https://img.shields.io/github/v/release/swartzrock/obsidian-settings-float-plugin?include_prereleases&label=release)](https://github.com/swartzrock/obsidian-settings-float-plugin/releases) +[![GitHub release date](https://img.shields.io/github/release-date/swartzrock/obsidian-settings-float-plugin)](https://github.com/swartzrock/obsidian-settings-float-plugin/releases) +[![Last commit](https://img.shields.io/github/last-commit/swartzrock/obsidian-settings-float-plugin)](https://github.com/swartzrock/obsidian-settings-float-plugin/commits/main) +[![Issues](https://img.shields.io/github/issues/swartzrock/obsidian-settings-float-plugin)](https://github.com/swartzrock/obsidian-settings-float-plugin/issues) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Settings Float is an Obsidian desktop plugin that makes the core Settings modal and supported catalog dialogs movable and resizable. It is built for appearance-tuning workflows where you want Settings, theme browsing, or plugin browsing open while still seeing the note or workspace behind it. + +## Demo + + + +## Table of Contents + +- [Installation](#installation) +- [Workflow](#workflow) +- [Settings](#settings) +- [Recovery](#recovery) +- [Development](#development) +- [Compatibility Notes](#compatibility-notes) +- [License](#license) + +## Installation + +Settings Float is not listed in Obsidian's community plugin catalog yet. Install it with [BRAT](https://github.com/TfTHacker/obsidian42-brat), from a GitHub release, or from a local build. + +### Install with BRAT + +BRAT is the easiest way to try unreleased Obsidian plugins and keep them updated from GitHub. + +1. In Obsidian, install and enable [BRAT](https://github.com/TfTHacker/obsidian42-brat) from Community plugins. +2. Open BRAT settings and choose `Add Beta plugin`. +3. Paste this repository URL: + + ```text + https://github.com/swartzrock/obsidian-settings-float-plugin + ``` + +4. Choose `Add Plugin`, then enable `Settings Float` from Obsidian's Community plugins list. + +### Install Manually from a Release + +1. Download the latest release from [GitHub Releases](https://github.com/swartzrock/obsidian-settings-float-plugin/releases). +2. In your vault, create this folder if it does not already exist: + + ```text + .obsidian/plugins/settings-float/ + ``` + +3. Copy these release files into that folder: + + ```text + manifest.json + main.js + styles.css + ``` + +4. Restart Obsidian or reload the app. +5. Enable `Settings Float` from `Settings -> Community plugins -> Installed plugins`. + +### Install from a Local Build + +Use this path when testing changes from source. + +1. Clone the repository and install dependencies: + + ```bash + git clone https://github.com/swartzrock/obsidian-settings-float-plugin.git + cd obsidian-settings-float-plugin + bun install + ``` + +2. Build the plugin: + + ```bash + bun run build + ``` + +3. Copy `manifest.json`, `main.js`, and `styles.css` into your vault's `.obsidian/plugins/settings-float/` folder. +4. Reload Obsidian and enable the plugin. + +## Workflow + +Open Obsidian Settings, move the modal by dragging empty space inside the dialog, then resize from the bottom-right corner. Theme and community plugin browser dialogs can also be moved and resized, which makes it easier to preview themes or inspect plugins without covering the whole workspace. + +The plugin enhances the main Settings modal plus Obsidian's theme and community plugin browser dialogs. Other nested dialogs, such as font pickers, are left to Obsidian's default behavior. + +## Settings + +- `Enable movable dialogs`: lets empty dialog space move supported dialogs. +- `Enable resizable dialogs`: shows the bottom-right resize corner on supported dialogs. +- `Remember window geometry`: restores the last valid position and size in this vault. +- `Disable on narrow windows`: keeps behavior conservative on cramped desktop windows. +- `Disable on mobile`: leaves mobile as a no-op for this release. +- `Reset saved geometry`: clears the persisted position and size. + +## Recovery + +If Settings ends up in an awkward position, use the reset button in the plugin settings tab. Saved geometry is validated and clamped to the visible Obsidian window before it is applied. + +## Development + +1. Install dependencies with `bun install`. +2. Run `bun run dev` for watch mode or `bun run build` for a production bundle. +3. Use `bun run typecheck`, `bun run lint`, and `bun run test` before packaging. +4. Release packaging expects `manifest.json`, `main.js`, and `styles.css`; the publish workflow also creates `settings-float-.zip`. + +## Compatibility Notes + +- Desktop is the intended target; mobile behavior is disabled for 0.1. +- The plugin relies on Obsidian's current Settings and catalog dialog DOM shapes. Selectors are centralized so they can be updated if Obsidian changes those structures. +- Custom themes may affect modal spacing, shadows, or scrollbar placement. The plugin uses isolated classes and Obsidian CSS variables where possible. +- Manual validation in Obsidian is still required before publishing a release. + +## License + +Settings Float is released under the [MIT License](LICENSE). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..cab3b79 --- /dev/null +++ b/bun.lock @@ -0,0 +1,609 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "settings-float", + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/jsdom": "^21.1.7", + "@types/node": "^24.0.4", + "esbuild": "^0.25.5", + "eslint": "^9.29.0", + "eslint-plugin-n": "^17.21.0", + "globals": "^16.2.0", + "jiti": "^2.4.2", + "jsdom": "^26.1.0", + "obsidian": "^1.8.10", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vitest": "^3.2.4", + }, + }, + }, + "packages": { + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="], + + "@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.1", "", { "os": "android", "cpu": "arm" }, "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.61.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.61.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.61.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.61.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.61.1", "", { "os": "linux", "cpu": "arm" }, "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.61.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.61.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.61.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.61.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.61.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.61.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.61.1", "", { "os": "none", "cpu": "arm64" }, "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.61.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.61.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.61.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw=="], + + "@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=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + + "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + + "@vitest/expect": ["@vitest/expect@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.6", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.6", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA=="], + + "@vitest/runner": ["@vitest/runner@3.2.6", "", { "dependencies": { "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw=="], + + "@vitest/spy": ["@vitest/spy@3.2.6", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg=="], + + "@vitest/utils": ["@vitest/utils@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.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", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.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", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + + "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], + + "eslint-plugin-es-x": ["eslint-plugin-es-x@7.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.1.2", "@eslint-community/regexpp": "^4.11.0", "eslint-compat-utils": "^0.5.1" }, "peerDependencies": { "eslint": ">=8" } }, "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ=="], + + "eslint-plugin-n": ["eslint-plugin-n@17.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "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=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + + "obsidian": ["obsidian@1.13.1", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": ">=4.0.0" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "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.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "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=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/docs/release-notes/0.1.0.md b/docs/release-notes/0.1.0.md new file mode 100644 index 0000000..be201bd --- /dev/null +++ b/docs/release-notes/0.1.0.md @@ -0,0 +1,31 @@ +# Settings Float 0.1.0 Release Notes + +Initial desktop release for moving and resizing the core Obsidian Settings modal. + +## Added + +- Detects and enhances only the main Obsidian Settings modal. +- Detects and enhances Obsidian's theme and community plugin browser dialogs. +- Moves Settings by dragging non-interactive empty space inside the dialog. +- Resizes Settings from a bottom-right corner grip. +- Persists valid Settings geometry per vault and restores it on reopen. +- Clamps geometry to the visible Obsidian window on open, resize, and host-window resize. +- Adds a plugin settings tab for movement, resizing, persistence, narrow-window, mobile, and reset controls. +- Leaves nested child dialogs, such as font pickers, untouched. + +## Compatibility + +- Desktop-only release. +- Mobile behavior is disabled. +- Settings modal selectors are centralized for future Obsidian DOM changes. +- Theme compatibility uses isolated CSS classes and Obsidian CSS variables, but custom themes still need manual validation. + +## Release Artifacts + +- `manifest.json` +- `main.js` +- `styles.css` + +## Manual QA Required + +Before publishing, verify Settings movement, resizing, persistence, nested dialogs, and host-window resize behavior in a real Obsidian vault. diff --git a/docs/requirements/movable-resizable-settings-prd.md b/docs/requirements/movable-resizable-settings-prd.md new file mode 100644 index 0000000..50f5102 --- /dev/null +++ b/docs/requirements/movable-resizable-settings-prd.md @@ -0,0 +1,196 @@ +--- +date: 2026-06-12 +topic: movable-resizable-settings +type: product_requirements +--- + +# Product Requirements: Movable and Resizable Obsidian Settings + +## Summary + +Build an Obsidian community plugin, written in TypeScript, that lets users move and resize the core Settings window and supported catalog dialogs. The primary use case is appearance tuning: users should be able to adjust fonts, themes, CSS snippets, zoom, and similar visual settings while still seeing the note or workspace behind Settings. + +The first release should be a focused utility, not a general modal/window manager. It should enhance the existing Settings modal and selected catalog browser dialogs with safe drag and resize behavior while avoiding private Obsidian API patches wherever possible. + +## Problem + +Obsidian Settings opens as a large centered modal that obscures the active workspace. When changing appearance-related settings, users cannot see the effect of their changes in context. They must close Settings, inspect the workspace, remember what changed, reopen Settings, and navigate back to the relevant section. + +This creates two pains: + +- Slow feedback cycles while tuning visual settings. +- Unnecessary memory burden because the setting value and visual result are separated in time. + +The motivating screenshot shows the Appearance settings page and a nested Text font dialog covering the note beneath, which means the plugin must handle both the main Settings modal and child dialogs carefully. + +## Goals + +- Let users move the Settings window so the active note/workspace remains visible. +- Let users resize the Settings window without breaking Settings layout or controls. +- Let users move and resize theme and community plugin browser dialogs so catalog browsing does not obscure live preview context. +- Remember safe window geometry between Settings opens. +- Provide recovery controls when the modal is moved or resized into an undesirable state. +- Avoid affecting unrelated Obsidian modals unless explicitly added in a later release. +- Keep behavior reversible and easy to disable. + +## Non-Goals + +- Do not recreate Obsidian Settings as a custom pane. +- Do not open Settings in a separate native Electron window. +- Do not patch Obsidian internals or monkey-patch Settings constructors for the first release. +- Do not make every Obsidian modal movable and resizable. +- Do not implement a screenshot comparison workflow. +- Do not replace themes, snippets, or Appearance settings themselves. + +## Target Users + +- Users who frequently customize themes, fonts, CSS snippets, and visual settings. +- Theme builders who need to see live visual effects while changing Settings. +- Plugin developers and power users who often move between Settings and live workspace state. +- Users on laptops or single-monitor setups where the Settings modal consumes most of the available screen. + +## Core User Stories + +- As a user changing my text font, I want to move Settings aside so I can see the current note while selecting fonts. +- As a theme customizer, I want to resize Settings so the Appearance controls remain usable while more of the workspace is visible. +- As a user choosing a theme, I want the theme browser dialog to move aside so I can see the theme applied to my workspace. +- As a user with a preferred layout, I want Settings to reopen in the same place and size next time. +- As a user who moves Settings too far, I want a settings-tab reset action to restore the default centered position. +- As a user interacting with a nested font picker, I want child dialogs to keep working normally. + +## Functional Requirements + +- R1. The plugin enhances the core Obsidian Settings modal when it opens. +- R1a. The plugin enhances Obsidian's theme and community plugin browser dialogs when they open. +- R2. The plugin adds a clear drag affordance to the Settings modal, preferably in the header or an inserted handle strip. +- R3. The plugin allows resizing the Settings modal from at least the bottom-right corner. +- R4. The plugin enforces minimum width and height values so Settings remains usable. +- R5. The plugin clamps the modal to the visible Obsidian window after drag, resize, open, and host window resize. +- R6. The plugin persists Settings modal geometry per vault. +- R7. The plugin validates persisted geometry before applying it. +- R8. The plugin exposes a settings-tab action to reset Settings geometry to Obsidian's default centered layout. +- R9. The plugin does not expose command palette actions for positioning in the first release. +- R10. The plugin can be disabled from its own settings tab. +- R11. The plugin does not apply drag or resize behavior to nested child dialogs by default. +- R12. The plugin does not interfere with form controls, sliders, dropdowns, buttons, search inputs, or nested modal focus. +- R13. The plugin cleans up all DOM additions and event listeners on unload. +- R14. The plugin uses isolated CSS class names to avoid theme conflicts. + +## Settings + +The plugin settings should be minimal for the first release: + +- Enable movable Settings: default on. +- Enable resizable Settings: default on. +- Remember last position and size: default on. +- Reset saved geometry: action button. +- Advanced: disable on mobile or narrow windows: default on. + +## Commands + +No command palette actions ship in version 0.1. + +Optional later commands: + +- Reset Settings window position and size. +- Center Settings. +- Dock Settings left. +- Dock Settings right. +- Toggle movable Settings. +- Narrow Settings. +- Widen Settings. +- Move Settings by keyboard increment. + +## Interaction Requirements + +Dragging: + +- Dragging should only start from the handle/header affordance. +- Dragging should not start from setting rows, text inputs, sliders, dropdowns, or buttons. +- Dragging should maintain pointer capture until release. +- Dragging should clamp the modal to the visible workspace. + +Resizing: + +- Resize should be available from an explicit handle. +- Resize should respect minimum dimensions. +- Resize should clamp maximum dimensions to the visible Obsidian window. +- Resize should not introduce scroll traps in the Settings content area. + +Nested dialogs: + +- Child dialogs, such as font selector dialogs, should remain centered or follow Obsidian's default behavior unless a later release intentionally supports positioning them. +- The plugin should avoid attaching handles to child dialogs. +- Theme and community plugin browser dialogs are explicitly supported exceptions because they block appearance/plugin browsing preview workflows. +- The plugin should preserve Escape, close buttons, focus management, and click-outside behavior. + +## Accessibility Requirements + +- Handles must have accessible labels if they are focusable or visible controls. +- The plugin must not make Settings unreachable by keyboard. +- The reset settings-tab action must clear saved geometry even if Settings is currently offscreen or partially clipped. +- Visual handles must have sufficient contrast across light and dark themes. + +## Compatibility Requirements + +- The plugin should target current Obsidian desktop releases and avoid mobile behavior in the first release unless testing proves it safe. +- The plugin should not rely on undocumented class names more than necessary. +- Any DOM selectors used to identify the Settings modal should be centralized and easy to update. +- The plugin should degrade gracefully if the Settings modal DOM shape changes. +- The plugin should use Obsidian CSS variables where possible. + +## Success Metrics + +- A user can change Appearance font settings while keeping at least part of the active note visible. +- A user can browse themes or community plugins while keeping at least part of the active workspace visible. +- A user can move and resize Settings without breaking Settings controls. +- A user can close and reopen Settings and see their preferred geometry restored. +- A user can recover from bad geometry using the plugin settings reset action. +- The plugin does not apply behavior to nested child dialogs in the motivating font-selection workflow. + +## Acceptance Criteria + +- AC1. Opening Settings with the plugin enabled shows a draggable affordance on the Settings modal. +- AC2. Dragging the affordance moves the Settings modal and does not trigger settings controls. +- AC3. Resizing from the resize handle changes modal dimensions and preserves usable Settings content. +- AC4. Closing and reopening Settings restores the last valid geometry when persistence is enabled. +- AC5. If the Obsidian window is resized, the Settings modal remains visible or is clamped back into view. +- AC6. The reset settings-tab action restores the default centered Settings layout on the next open and immediately recenters the current Settings modal if it is open. +- AC7. The plugin does not add center, dock-left, dock-right, reset, or toggle-movable command palette actions in version 0.1. +- AC8. Opening the Text font dialog from Appearance settings does not add drag/resize handles to that child dialog. +- AC8a. Opening the theme or community plugin browser adds drag/resize behavior to that browser dialog. +- AC9. Disabling the plugin removes its handles/classes and returns Settings behavior to normal after reopening Settings. +- AC10. Unloading the plugin removes event listeners and DOM additions without requiring an Obsidian restart. + +## Release Scope + +### Version 0.1 + +- Detect and enhance the core Settings modal. +- Detect and enhance theme and community plugin browser dialogs. +- Add drag handle. +- Add bottom-right resize handle. +- Persist and validate geometry. +- Add minimal plugin settings. +- Disable or no-op on mobile/narrow windows. +- Document limitations and recovery behavior. + +### Later Versions + +- Keyboard increment move/resize controls. +- Named geometry profiles for laptop and external monitor layouts. +- Optional child-dialog positioning. +- Optional visible preset buttons inside the Settings modal. +- Broader support for selected non-Settings modals if users ask for it. + +## Risks and Open Questions + +- Obsidian may change Settings modal DOM structure, requiring selector updates. +- Themes may style modals in ways that conflict with inserted handles. +- Nested modal behavior may vary across Settings sections and community plugin settings tabs. +- The plugin needs real manual testing inside Obsidian because DOM behavior is difficult to validate with unit tests alone. +- It is unclear whether the Settings modal should persist geometry globally or per vault; per vault is the safer first assumption. + +## Source Ideation + +This requirements document is based on the ideation artifact at `docs/ideation/2026-06-12-movable-resizable-obsidian-settings-ideation.html`. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..c9cc9df --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,23 @@ +import esbuild from "esbuild"; + +const production = process.argv.includes("production"); + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: ["obsidian", "electron", "@codemirror/*"], + format: "cjs", + target: "es2020", + platform: "browser", + logLevel: "info", + sourcemap: production ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (production) { + await context.rebuild(); + await context.dispose(); +} else { + await context.watch(); +} diff --git a/eslint.config.mts b/eslint.config.mts new file mode 100644 index 0000000..1a537d8 --- /dev/null +++ b/eslint.config.mts @@ -0,0 +1,41 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["main.js", "node_modules/", "coverage/"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["*.config.mjs", "*.config.mts"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.node, + }, + }, + }, + { + files: ["src/**/*.ts", "tests/**/*.ts"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ] + } + } +); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..db471ec --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "settings-float", + "name": "Settings Float", + "version": "0.1.0", + "minAppVersion": "1.6.0", + "description": "Makes Obsidian Settings and catalog dialogs movable and resizable.", + "author": "Jason Swartz", + "authorUrl": "https://github.com/swartzrock", + "isDesktopOnly": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d5359d --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "settings-float", + "version": "0.1.0", + "description": "Obsidian plugin that makes Settings and catalog dialogs movable and resizable.", + "license": "MIT", + "author": "Jason Swartz", + "repository": { + "type": "git", + "url": "git+https://github.com/swartzrock/settings-float.git" + }, + "bugs": { + "url": "https://github.com/swartzrock/settings-float/issues" + }, + "homepage": "https://github.com/swartzrock/settings-float#readme", + "keywords": [ + "obsidian", + "obsidian-plugin", + "settings", + "modal", + "appearance" + ], + "packageManager": "bun@1.3.14", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run --environment jsdom" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/jsdom": "^21.1.7", + "@types/node": "^24.0.4", + "eslint": "^9.29.0", + "eslint-plugin-n": "^17.21.0", + "globals": "^16.2.0", + "jiti": "^2.4.2", + "jsdom": "^26.1.0", + "obsidian": "^1.8.10", + "esbuild": "^0.25.5", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vitest": "^3.2.4" + } +} diff --git a/src/drag-resize.ts b/src/drag-resize.ts new file mode 100644 index 0000000..5b4c3b2 --- /dev/null +++ b/src/drag-resize.ts @@ -0,0 +1,156 @@ +import { clampModalRect, type ModalRect } from "./geometry"; + +export type DragResizeMode = "drag" | "resize"; + +export interface DragResizeSessionOptions { + isEnabled: () => boolean; + handleEl: HTMLElement; + modalEl: HTMLElement; + mode: DragResizeMode; + canStart?: (target: Element) => boolean; + getCurrentRect: () => ModalRect; + getHostBounds: () => { + x: number; + y: number; + width: number; + height: number; + }; + onUpdate: (rect: ModalRect) => void; + onCommit?: (rect: ModalRect) => void; + onStateChange?: (active: boolean) => void; +} + +export interface DragResizeSession { + destroy(): void; +} + +interface ActiveInteraction { + pointerId: number; + startX: number; + startY: number; + startRect: ModalRect; +} + +export function createDragResizeSession( + options: DragResizeSessionOptions, +): DragResizeSession { + let active: ActiveInteraction | null = null; + + const onPointerDown = (event: PointerEvent): void => { + if (!options.isEnabled() || event.button !== 0) { + return; + } + + const target = event.target; + if (!(target instanceof options.modalEl.ownerDocument.defaultView!.Element)) { + return; + } + + if (options.canStart && !options.canStart(target)) { + return; + } + if (options.mode === "resize" && !target.closest("[data-setmove-role='resize-handle']")) { + return; + } + + active = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startRect: options.getCurrentRect(), + }; + + options.handleEl.setPointerCapture?.(event.pointerId); + options.modalEl.classList.add("setmove--is-interacting"); + options.modalEl.classList.add(`setmove--is-${options.mode === "drag" ? "dragging" : "resizing"}`); + options.modalEl.style.userSelect = "none"; + options.onStateChange?.(true); + event.preventDefault(); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (!active || event.pointerId !== active.pointerId) { + return; + } + + const deltaX = event.clientX - active.startX; + const deltaY = event.clientY - active.startY; + const nextRect = + options.mode === "drag" + ? { + ...active.startRect, + x: active.startRect.x + deltaX, + y: active.startRect.y + deltaY, + } + : { + ...active.startRect, + width: active.startRect.width + deltaX, + height: active.startRect.height + deltaY, + }; + + const clamped = clampModalRect(nextRect, options.getHostBounds()); + options.onUpdate(clamped); + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!active || event.pointerId !== active.pointerId) { + return; + } + + const deltaX = event.clientX - active.startX; + const deltaY = event.clientY - active.startY; + const nextRect = + options.mode === "drag" + ? { + ...active.startRect, + x: active.startRect.x + deltaX, + y: active.startRect.y + deltaY, + } + : { + ...active.startRect, + width: active.startRect.width + deltaX, + height: active.startRect.height + deltaY, + }; + + const clamped = clampModalRect(nextRect, options.getHostBounds()); + options.onUpdate(clamped); + options.onCommit?.(clamped); + finishInteraction(event.pointerId); + }; + + const onPointerCancel = (event: PointerEvent): void => { + if (!active || event.pointerId !== active.pointerId) { + return; + } + + options.onUpdate(active.startRect); + finishInteraction(event.pointerId); + }; + + const finishInteraction = (pointerId: number): void => { + options.handleEl.releasePointerCapture?.(pointerId); + options.modalEl.classList.remove("setmove--is-interacting"); + options.modalEl.classList.remove("setmove--is-dragging", "setmove--is-resizing"); + options.modalEl.style.userSelect = ""; + options.onStateChange?.(false); + active = null; + }; + + options.handleEl.addEventListener("pointerdown", onPointerDown); + options.handleEl.addEventListener("pointermove", onPointerMove); + options.handleEl.addEventListener("pointerup", onPointerUp); + options.handleEl.addEventListener("pointercancel", onPointerCancel); + + return { + destroy(): void { + if (active) { + finishInteraction(active.pointerId); + } + + options.handleEl.removeEventListener("pointerdown", onPointerDown); + options.handleEl.removeEventListener("pointermove", onPointerMove); + options.handleEl.removeEventListener("pointerup", onPointerUp); + options.handleEl.removeEventListener("pointercancel", onPointerCancel); + }, + }; +} diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..5890649 --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,232 @@ +import type { PersistedGeometry } from "./settings"; + +export interface HostBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface ModalRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface Size { + width: number; + height: number; +} + +export type GeometryPreset = "center" | "dock-left" | "dock-right" | "bottom-half"; + +// These minimums keep the Settings sidebar, content column, and footer controls usable. +export const MIN_MODAL_WIDTH = 640; +export const MIN_MODAL_HEIGHT = 420; + +const HALF_HEIGHT_RATIO = 0.5; + +export function measureHostBounds(viewport: Pick): HostBounds { + return { + x: 0, + y: 0, + width: sanitizePositiveNumber(viewport.innerWidth) ?? 0, + height: sanitizePositiveNumber(viewport.innerHeight) ?? 0, + }; +} + +export function getMinimumModalSize(hostBounds: HostBounds): Size { + return { + width: Math.min(MIN_MODAL_WIDTH, Math.max(hostBounds.width, 0)), + height: Math.min(MIN_MODAL_HEIGHT, Math.max(hostBounds.height, 0)), + }; +} + +export function isUsableHostBounds(hostBounds: HostBounds): boolean { + return ( + Number.isFinite(hostBounds.x) && + Number.isFinite(hostBounds.y) && + hostBounds.width > 0 && + hostBounds.height > 0 + ); +} + +export function clampModalRect(rect: ModalRect, hostBounds: HostBounds): ModalRect { + const normalizedHost = normalizeHostBounds(hostBounds); + const minimumSize = getMinimumModalSize(normalizedHost); + const width = clampDimension(rect.width, normalizedHost.width, minimumSize.width); + const height = clampDimension(rect.height, normalizedHost.height, minimumSize.height); + const maxX = normalizedHost.x + normalizedHost.width - width; + const maxY = normalizedHost.y + normalizedHost.height - height; + + return { + x: clampNumber(rect.x, normalizedHost.x, maxX), + y: clampNumber(rect.y, normalizedHost.y, maxY), + width, + height, + }; +} + +export function getCenteredRect( + rect: Pick, + hostBounds: HostBounds, +): ModalRect { + const normalizedHost = normalizeHostBounds(hostBounds); + const clampedRect = clampModalRect( + { + x: normalizedHost.x, + y: normalizedHost.y, + width: rect.width, + height: rect.height, + }, + normalizedHost, + ); + + return { + ...clampedRect, + x: normalizedHost.x + (normalizedHost.width - clampedRect.width) / 2, + y: normalizedHost.y + (normalizedHost.height - clampedRect.height) / 2, + }; +} + +export function applyGeometryPreset( + preset: GeometryPreset, + currentRect: Pick, + hostBounds: HostBounds, +): ModalRect { + const normalizedHost = normalizeHostBounds(hostBounds); + const minimumSize = getMinimumModalSize(normalizedHost); + + if (preset === "center") { + return getCenteredRect(currentRect, normalizedHost); + } + + if (preset === "bottom-half") { + const height = clampDimension( + normalizedHost.height, + normalizedHost.height * HALF_HEIGHT_RATIO, + minimumSize.height, + ); + + return clampModalRect( + { + x: normalizedHost.x, + y: normalizedHost.y + normalizedHost.height - height, + width: normalizedHost.width, + height, + }, + normalizedHost, + ); + } + + const width = minimumSize.width; + + return clampModalRect( + { + x: + preset === "dock-right" + ? normalizedHost.x + normalizedHost.width - width + : normalizedHost.x, + y: normalizedHost.y, + width, + height: normalizedHost.height, + }, + normalizedHost, + ); +} + +export function getRestoredGeometry( + geometry: PersistedGeometry | null, + hostBounds: HostBounds, +): ModalRect | null { + if (geometry === null) { + return null; + } + + if (!isUsableHostBounds(hostBounds)) { + return null; + } + + if ( + !isPersistedGeometryCompatible(geometry) || + geometry.width < MIN_MODAL_WIDTH || + geometry.height < MIN_MODAL_HEIGHT + ) { + return null; + } + + const maximumReasonableWidth = Math.max( + geometry.lastAppliedBounds.width, + hostBounds.width, + ); + const maximumReasonableHeight = Math.max( + geometry.lastAppliedBounds.height, + hostBounds.height, + ); + + if ( + geometry.width > maximumReasonableWidth || + geometry.height > maximumReasonableHeight + ) { + return null; + } + + return clampModalRect(geometry, hostBounds); +} + +function isPersistedGeometryCompatible(geometry: PersistedGeometry): boolean { + return ( + isFiniteRect(geometry) && + geometry.lastAppliedBounds.width > 0 && + geometry.lastAppliedBounds.height > 0 && + geometry.x >= 0 && + geometry.y >= 0 + ); +} + +function isFiniteRect(rect: Pick): boolean { + return ( + Number.isFinite(rect.x) && + Number.isFinite(rect.y) && + Number.isFinite(rect.width) && + Number.isFinite(rect.height) + ); +} + +function normalizeHostBounds(hostBounds: HostBounds): HostBounds { + return { + x: Number.isFinite(hostBounds.x) ? hostBounds.x : 0, + y: Number.isFinite(hostBounds.y) ? hostBounds.y : 0, + width: sanitizePositiveNumber(hostBounds.width) ?? 0, + height: sanitizePositiveNumber(hostBounds.height) ?? 0, + }; +} + +function clampDimension( + value: number, + available: number, + minimum: number, +): number { + const safeAvailable = sanitizePositiveNumber(available) ?? 0; + const safeMinimum = Math.min(sanitizePositiveNumber(minimum) ?? 0, safeAvailable); + const safeValue = sanitizePositiveNumber(value) ?? safeMinimum; + + if (safeAvailable === 0) { + return 0; + } + + return clampNumber(safeValue, safeMinimum, safeAvailable); +} + +function clampNumber(value: number, minimum: number, maximum: number): number { + if (maximum < minimum) { + return minimum; + } + + return Math.min(Math.max(value, minimum), maximum); +} + +function sanitizePositiveNumber(value: number): number | null { + return Number.isFinite(value) && value > 0 ? value : null; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..129b64f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,120 @@ +import { Notice, Plugin, Platform } from "obsidian"; +import { registerSettingsModalLifecycle } from "./modal-detector"; +import { + enhanceSettingsModal, + type SettingsModalEnhancer, + type SettingsModalEnhancerOptions, +} from "./modal-enhancer"; +import { SetmoveSettingTab } from "./settings-tab"; +import { + DEFAULT_SETTINGS, + clearSavedGeometry, + loadSettings, + saveSettings, + type PersistedGeometry, + type SetmoveSettings, +} from "./settings"; + +export default class SetmovePlugin extends Plugin { + settings: SetmoveSettings = DEFAULT_SETTINGS; + private stopModalLifecycle: (() => void) | null = null; + private readonly activeEnhancers = new Set(); + + async onload(): Promise { + this.settings = await loadSettings(this); + await saveSettings(this, this.settings); + this.addSettingTab(new SetmoveSettingTab(this)); + + if (Platform.isMobile) { + new Notice("Setmove is disabled on mobile."); + return; + } + + this.stopModalLifecycle = registerSettingsModalLifecycle(this.app.workspace, { + onAttach: (match) => { + const enhancer = enhanceSettingsModal(match, { + settings: this.getEnhancerSettings(match.kind), + onGeometryPersist: match.kind === "settings" + ? async (geometry) => { + await this.persistGeometry(geometry); + } + : undefined, + onResetGeometry: + match.kind === "settings" + ? async () => { + await this.resetSavedGeometryCommand(); + } + : undefined, + }); + this.activeEnhancers.add(enhancer); + + return () => { + this.activeEnhancers.delete(enhancer); + enhancer.destroy(); + }; + }, + }); + this.register(() => { + this.stopModalLifecycle?.(); + this.stopModalLifecycle = null; + }); + + console.log(`[${this.manifest.name}] loaded`); + } + + onunload(): void { + this.stopModalLifecycle?.(); + this.stopModalLifecycle = null; + console.log(`[${this.manifest.name}] unloaded`); + } + + async updateSettings(partial: Partial): Promise { + this.settings = { + ...this.settings, + ...partial, + }; + await saveSettings(this, this.settings); + this.applySettingsToOpenEnhancers(); + } + + async resetSavedGeometryCommand(): Promise { + this.settings = clearSavedGeometry(this.settings); + await saveSettings(this, this.settings); + + const enhancer = this.getOpenEnhancer(); + if (enhancer) { + enhancer.reset(); + } + + this.applySettingsToOpenEnhancers(); + new Notice("Reset saved Settings window geometry."); + } + + private async persistGeometry(geometry: PersistedGeometry): Promise { + this.settings = { + ...this.settings, + geometry, + }; + await saveSettings(this, this.settings); + } + + private applySettingsToOpenEnhancers(): void { + for (const enhancer of this.activeEnhancers) { + enhancer.updateSettings(this.getEnhancerSettings(enhancer.kind)); + } + } + + private getOpenEnhancer(): SettingsModalEnhancer | null { + return this.activeEnhancers.values().next().value ?? null; + } + + private getEnhancerSettings( + kind: SettingsModalEnhancer["kind"], + ): SettingsModalEnhancerOptions["settings"] { + return { + ...this.settings, + geometry: kind === "settings" ? this.settings.geometry : null, + rememberGeometry: kind === "settings" && this.settings.rememberGeometry, + }; + } +} diff --git a/src/modal-detector.ts b/src/modal-detector.ts new file mode 100644 index 0000000..704b9ef --- /dev/null +++ b/src/modal-detector.ts @@ -0,0 +1,314 @@ +import type { Workspace } from "obsidian"; + +export const SETTINGS_MODAL_SELECTORS = Object.freeze({ + modal: ".modal.mod-settings", + modalContainer: ".modal-container", + closeButton: ".modal-close-button", + sidebar: ".vertical-tab-header", + content: ".vertical-tab-content-container", + anyModal: ".modal", + searchInput: "input[type='search'], input[type='text'], input:not([type])", +}); + +export type EnhancedModalKind = "settings" | "catalog"; + +export interface SettingsModalMatch { + modalEl: HTMLElement; + containerEl: HTMLElement; + contentEl: HTMLElement | null; + doc: Document; + kind: EnhancedModalKind; + win: Window; +} + +export interface SettingsModalLifecycleCallbacks { + onAttach(match: SettingsModalMatch): void | (() => void); + onDetach?(match: SettingsModalMatch): void; +} + +interface AttachedModalRecord { + cleanup?: () => void; + match: SettingsModalMatch; +} + +export class SettingsModalLifecycle { + private readonly observers = new Map(); + private readonly attached = new Map(); + + constructor(private readonly callbacks: SettingsModalLifecycleCallbacks) {} + + trackDocument(doc: Document): void { + if (this.observers.has(doc)) { + this.scanDocument(doc); + return; + } + + const root = doc.body ?? doc.documentElement; + if (!root) { + return; + } + + const ViewMutationObserver = doc.defaultView?.MutationObserver ?? MutationObserver; + const observer = new ViewMutationObserver(() => { + this.scanDocument(doc); + }); + + observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["class", "placeholder", "type"], + }); + + this.observers.set(doc, observer); + this.scanDocument(doc); + } + + untrackDocument(doc: Document): void { + const observer = this.observers.get(doc); + observer?.disconnect(); + this.observers.delete(doc); + + for (const [modalEl, record] of this.attached) { + if (record.match.doc === doc) { + this.detachModal(modalEl, record); + } + } + } + + stop(): void { + for (const observer of this.observers.values()) { + observer.disconnect(); + } + this.observers.clear(); + + for (const [modalEl, record] of this.attached) { + this.detachModal(modalEl, record); + } + } + + private scanDocument(doc: Document): void { + const matches = findSettingsModals(doc); + const activeModals = new Set(matches.map((match) => match.modalEl)); + + for (const match of matches) { + if (this.attached.has(match.modalEl)) { + continue; + } + + const cleanup = this.callbacks.onAttach(match) ?? undefined; + this.attached.set(match.modalEl, { cleanup, match }); + } + + for (const [modalEl, record] of this.attached) { + if (record.match.doc !== doc) { + continue; + } + + if (!modalEl.isConnected || !activeModals.has(modalEl)) { + this.detachModal(modalEl, record); + } + } + } + + private detachModal( + modalEl: HTMLElement, + record: AttachedModalRecord, + ): void { + this.attached.delete(modalEl); + record.cleanup?.(); + this.callbacks.onDetach?.(record.match); + } +} + +export function registerSettingsModalLifecycle( + workspace: Workspace, + callbacks: SettingsModalLifecycleCallbacks, +): () => void { + const lifecycle = new SettingsModalLifecycle(callbacks); + + lifecycle.trackDocument(window.document); + + const openRef = workspace.on("window-open", (workspaceWindow, popoutWindow) => { + lifecycle.trackDocument(workspaceWindow.doc ?? popoutWindow.document); + }); + + const closeRef = workspace.on( + "window-close", + (workspaceWindow, popoutWindow) => { + lifecycle.untrackDocument(workspaceWindow.doc ?? popoutWindow.document); + }, + ); + + return () => { + workspace.offref(openRef); + workspace.offref(closeRef); + lifecycle.stop(); + }; +} + +export function findSettingsModal(root: ParentNode): SettingsModalMatch | null { + return findSettingsModals(root)[0] ?? null; +} + +export function findSettingsModals(root: ParentNode): SettingsModalMatch[] { + const matches: SettingsModalMatch[] = []; + const modalEls = Array.from( + root.querySelectorAll(SETTINGS_MODAL_SELECTORS.anyModal), + ); + + for (const modalEl of modalEls) { + const match = toEnhancedModalMatch(modalEl); + if (match) { + matches.push(match); + } + } + + return matches; +} + +export function isSettingsModalElement(element: Element | null): element is HTMLElement { + return toSettingsModalMatch(element) !== null; +} + +function toSettingsModalMatch(element: Element | null): SettingsModalMatch | null { + if (!isHTMLElement(element)) { + return null; + } + + if (!element.matches(SETTINGS_MODAL_SELECTORS.modal)) { + return null; + } + + const containerEl = element.closest( + SETTINGS_MODAL_SELECTORS.modalContainer, + ); + + if (!containerEl) { + return null; + } + + const contentEl = element.querySelector(SETTINGS_MODAL_SELECTORS.content); + const hasRequiredStructure = + isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.sidebar)) && + isHTMLElement(contentEl) && + isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.closeButton)); + + if (!hasRequiredStructure) { + return null; + } + + const doc = element.ownerDocument; + const win = doc.defaultView; + + if (!win) { + return null; + } + + return { + modalEl: element, + containerEl, + contentEl, + doc, + kind: "settings", + win, + }; +} + +function toEnhancedModalMatch(element: Element | null): SettingsModalMatch | null { + return toSettingsModalMatch(element) ?? toCatalogModalMatch(element); +} + +function toCatalogModalMatch(element: Element | null): SettingsModalMatch | null { + if (!isHTMLElement(element)) { + return null; + } + + if (!element.matches(SETTINGS_MODAL_SELECTORS.anyModal)) { + return null; + } + + if (element.matches(SETTINGS_MODAL_SELECTORS.modal)) { + return null; + } + + const containerEl = element.closest( + SETTINGS_MODAL_SELECTORS.modalContainer, + ); + + if (!containerEl) { + return null; + } + + if (!isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.closeButton))) { + return null; + } + + if (!looksLikeCatalogBrowser(element)) { + return null; + } + + const doc = element.ownerDocument; + const win = doc.defaultView; + + if (!win) { + return null; + } + + return { + modalEl: element, + containerEl, + contentEl: null, + doc, + kind: "catalog", + win, + }; +} + +function looksLikeCatalogBrowser(modalEl: HTMLElement): boolean { + const text = modalEl.textContent ?? ""; + const hasCatalogCount = /\bShowing\s+[\d,]+\s+(themes|plugins)\b/i.test(text); + const hasCatalogControls = + /\bShow installed only\b/i.test(text) && + (/\bLight themes only\b/i.test(text) || + /\bthemes\b/i.test(text) || + /\bplugins\b/i.test(text)); + const hasCatalogResults = Boolean( + modalEl.querySelector( + [ + ".community-modal-search-results", + ".community-item", + ".community-item-name", + ".theme-card", + ".theme-list", + ].join(", "), + ), + ); + const hasSearchInput = Array.from( + modalEl.querySelectorAll(SETTINGS_MODAL_SELECTORS.searchInput), + ).some((input) => { + const placeholder = input.getAttribute("placeholder") ?? ""; + return /filter|search community plugins|search/i.test(placeholder); + }); + + return hasSearchInput && (hasCatalogCount || hasCatalogControls || hasCatalogResults); +} + +function isHTMLElement(value: unknown): value is HTMLElement { + if ( + typeof value !== "object" || + value === null || + !("ownerDocument" in value) || + !("nodeType" in value) || + value.nodeType !== Node.ELEMENT_NODE + ) { + return false; + } + + const element = value as { + ownerDocument: Document; + }; + const view = element.ownerDocument.defaultView; + return Boolean(view && value instanceof view.HTMLElement); +} diff --git a/src/modal-enhancer.ts b/src/modal-enhancer.ts new file mode 100644 index 0000000..e372264 --- /dev/null +++ b/src/modal-enhancer.ts @@ -0,0 +1,535 @@ +import { createDragResizeSession, type DragResizeSession } from "./drag-resize"; +import { + applyGeometryPreset, + clampModalRect, + getCenteredRect, + getRestoredGeometry, + measureHostBounds, + type GeometryPreset, + type ModalRect, +} from "./geometry"; +import type { SettingsModalMatch } from "./modal-detector"; +import type { PersistedGeometry, SetmoveSettings } from "./settings"; + +const ENHANCED_CLASS = "setmove--settings-modal"; +const HANDLE_CLASS = "setmove--drag-handle"; +const RESIZE_HANDLE_CLASS = "setmove--resize-handle"; +const CONTENT_CLASS = "setmove--settings-content"; +const NON_DRAGGABLE_SELECTOR = [ + "input", + "textarea", + "select", + "button", + "a[href]", + "[contenteditable='true']", + ".setting-item", + ".setting-item-control", + ".vertical-tab-nav-item", + ".clickable-icon", + ".slider", + ".dropdown", + "[data-setmove-role='resize-handle']", + "[data-setmove-role='preset-control']", +].join(", "); +const CATALOG_NON_DRAGGABLE_SELECTOR = [ + ".community-item", + ".community-modal-search-results", + ".community-modal-details", + ".theme-card", + ".theme-list", +].join(", "); +const ENHANCEABLE_MODAL_SELECTOR = ".modal.mod-settings, .modal.setmove--settings-modal"; +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const PRESET_ICON_COLOR = "#777799"; +type IconDefinition = { + fillPaths?: string[]; + fillRects?: Array<{ height: number; width: number; x: number; y: number }>; + paths?: string[]; +}; +const ICON_DEFINITIONS = { + center: { + fillRects: [{ x: 8, y: 7, width: 8, height: 10 }], + paths: ["M4 5h16v14H4z"], + }, + left: { + fillRects: [{ x: 4, y: 5, width: 5, height: 14 }], + paths: ["M4 5h16v14H4z", "M9 5v14"], + }, + right: { + fillRects: [{ x: 15, y: 5, width: 5, height: 14 }], + paths: ["M4 5h16v14H4z", "M15 5v14"], + }, + reset: { + fillPaths: [ + "M6.7 7.4A8.6 8.6 0 1 1 5 16.6l2.3-1.3a5.9 5.9 0 1 0 1-6.3l2.1 2.1H4.2V4.9z", + ], + }, +} as const satisfies Record; + +type PresetControlsPlacement = + | "top-right-horizontal" + | "bottom-right-horizontal" + | "bottom-left-vertical"; +const VISIBLE_PRESET_CONTROLS_PLACEMENTS = new Set([ + "bottom-right-horizontal", +]); + +const ENHANCER_BY_MODAL = new WeakMap(); + +interface InlineStyleSnapshot { + height: string; + left: string; + maxHeight: string; + maxWidth: string; + position: string; + top: string; + width: string; +} + +export interface SettingsModalEnhancerOptions { + settings: Pick< + SetmoveSettings, + "movable" | "resizable" | "rememberGeometry" | "geometry" + >; + onGeometryPersist?: (geometry: PersistedGeometry) => void | Promise; + onResetGeometry?: () => void | Promise; +} + +export class SettingsModalEnhancer { + readonly modalEl: HTMLElement; + readonly containerEl: HTMLElement; + readonly doc: Document; + readonly kind: SettingsModalMatch["kind"]; + readonly win: Window; + + private readonly contentEl: HTMLElement | null; + private readonly dragHandleEl: HTMLDivElement; + private readonly defaultSize: Pick; + private readonly presetControlsEls: HTMLDivElement[]; + private readonly resizeHandleEl: HTMLButtonElement; + private readonly styleSnapshot: InlineStyleSnapshot; + private readonly dragSession: DragResizeSession; + private readonly resizeSession: DragResizeSession; + private readonly onWindowResize = (): void => { + if (!this.enabled || this.currentRect === null) { + return; + } + + this.currentRect = clampModalRect(this.currentRect, this.getHostBounds()); + this.applyRect(this.currentRect); + }; + + private settings: SettingsModalEnhancerOptions["settings"]; + private readonly onGeometryPersist?: SettingsModalEnhancerOptions["onGeometryPersist"]; + private readonly onResetGeometry?: SettingsModalEnhancerOptions["onResetGeometry"]; + private enabled = true; + private currentRect: ModalRect | null = null; + + constructor(match: SettingsModalMatch, options: SettingsModalEnhancerOptions) { + this.modalEl = match.modalEl; + this.containerEl = match.containerEl; + this.doc = match.doc; + this.kind = match.kind; + this.win = match.win; + this.settings = { ...options.settings }; + this.onGeometryPersist = options.onGeometryPersist; + this.onResetGeometry = options.onResetGeometry; + this.contentEl = match.contentEl; + this.dragHandleEl = this.createDragHandle(); + this.defaultSize = this.measureModalRect(); + this.presetControlsEls = [ + this.createPresetControls("top-right-horizontal"), + this.createPresetControls("bottom-right-horizontal"), + this.createPresetControls("bottom-left-vertical"), + ]; + this.resizeHandleEl = this.createResizeHandle(); + this.styleSnapshot = this.captureInlineStyles(); + this.dragSession = this.createSession("drag", this.dragHandleEl); + this.resizeSession = this.createSession("resize", this.resizeHandleEl); + + this.attach(); + } + + center(): ModalRect { + const nextRect = getCenteredRect(this.getCurrentSize(), this.getHostBounds()); + this.currentRect = nextRect; + this.applyRect(nextRect); + return nextRect; + } + + reset(): ModalRect { + const nextRect = getCenteredRect(this.defaultSize, this.getHostBounds()); + this.currentRect = nextRect; + this.applyRect(nextRect); + return nextRect; + } + + dockLeft(): ModalRect { + return this.applyPreset("dock-left"); + } + + dockRight(): ModalRect { + return this.applyPreset("dock-right"); + } + + async persistCurrentGeometry(): Promise { + if (this.currentRect) { + await this.persistGeometry(this.currentRect); + } + } + + toggleEnabled(force?: boolean): boolean { + this.enabled = force ?? !this.enabled; + this.modalEl.classList.toggle(`${ENHANCED_CLASS}--disabled`, !this.enabled); + this.syncHandleState( + this.dragHandleEl, + this.enabled && this.settings.movable, + "move", + ); + this.syncHandleState( + this.resizeHandleEl, + this.enabled && this.settings.resizable, + "nwse-resize", + ); + for (const controlsEl of this.presetControlsEls) { + const isVisible = + this.enabled && + VISIBLE_PRESET_CONTROLS_PLACEMENTS.has( + controlsEl.dataset.setmovePlacement as PresetControlsPlacement, + ); + controlsEl.hidden = !isVisible; + controlsEl.style.display = isVisible ? "" : "none"; + } + + if (this.enabled) { + this.currentRect = this.currentRect ?? this.resolveInitialRect(); + this.applyRect(this.currentRect); + } else { + this.restoreInlineStyles(); + } + + return this.enabled; + } + + updateSettings( + settings: Partial, + ): void { + this.settings = { + ...this.settings, + ...settings, + }; + this.toggleEnabled(this.enabled); + } + + destroy(): void { + this.win.removeEventListener("resize", this.onWindowResize); + this.dragSession.destroy(); + this.resizeSession.destroy(); + ENHANCER_BY_MODAL.delete(this.modalEl); + this.dragHandleEl.remove(); + for (const controlsEl of this.presetControlsEls) { + controlsEl.remove(); + } + this.resizeHandleEl.remove(); + this.modalEl.classList.remove(ENHANCED_CLASS, `${ENHANCED_CLASS}--disabled`); + if (this.contentEl) { + this.contentEl.classList.remove(CONTENT_CLASS); + this.contentEl.style.minHeight = ""; + this.contentEl.style.overflow = ""; + } + this.restoreInlineStyles(); + } + + private attach(): void { + this.modalEl.classList.add(ENHANCED_CLASS); + if (this.contentEl) { + this.contentEl.classList.add(CONTENT_CLASS); + this.contentEl.style.minHeight = "0"; + this.contentEl.style.overflow = "auto"; + } + + this.modalEl.prepend(this.dragHandleEl); + this.modalEl.append(...this.presetControlsEls); + this.modalEl.append(this.resizeHandleEl); + + this.currentRect = this.resolveInitialRect(); + this.applyRect(this.currentRect); + this.toggleEnabled(true); + this.win.addEventListener("resize", this.onWindowResize); + } + + private applyPreset(preset: GeometryPreset): ModalRect { + const nextRect = applyGeometryPreset( + preset, + this.getCurrentSize(), + this.getHostBounds(), + ); + this.currentRect = nextRect; + this.applyRect(nextRect); + return nextRect; + } + + private applyRect(rect: ModalRect): void { + this.currentRect = rect; + this.modalEl.style.position = "fixed"; + this.modalEl.style.left = `${rect.x}px`; + this.modalEl.style.top = `${rect.y}px`; + this.modalEl.style.width = `${rect.width}px`; + this.modalEl.style.height = `${rect.height}px`; + this.modalEl.style.maxWidth = "none"; + this.modalEl.style.maxHeight = "none"; + } + + private resolveInitialRect(): ModalRect { + if (this.settings.rememberGeometry) { + const restored = getRestoredGeometry(this.settings.geometry, this.getHostBounds()); + if (restored) { + return restored; + } + } + + return getCenteredRect(this.measureModalRect(), this.getHostBounds()); + } + + private getCurrentSize(): Pick { + return this.currentRect ?? this.measureModalRect(); + } + + private getCurrentRect(): ModalRect { + return this.currentRect + ? { ...this.currentRect } + : { + ...this.measureModalRect(), + x: this.modalEl.offsetLeft || 0, + y: this.modalEl.offsetTop || 0, + }; + } + + private measureModalRect(): Pick { + const rect = this.modalEl.getBoundingClientRect(); + return { + width: rect.width || this.modalEl.offsetWidth || 900, + height: rect.height || this.modalEl.offsetHeight || 640, + }; + } + + private getHostBounds() { + return measureHostBounds(this.win); + } + + private createDragHandle(): HTMLDivElement { + const handle = this.doc.createElement("div"); + handle.className = HANDLE_CLASS; + handle.dataset.setmoveRole = "drag-handle"; + handle.setAttribute("role", "presentation"); + return handle; + } + + private createPresetControls( + placement: PresetControlsPlacement, + ): HTMLDivElement { + const controls = this.doc.createElement("div"); + controls.className = `setmove--preset-controls setmove--preset-controls-${placement}`; + controls.dataset.setmoveRole = "preset-controls"; + controls.dataset.setmovePlacement = placement; + + this.addPresetButton(controls, "Dock window left", "left", async () => { + this.dockLeft(); + await this.persistCurrentGeometry(); + }); + this.addPresetButton(controls, "Dock window right", "right", async () => { + this.dockRight(); + await this.persistCurrentGeometry(); + }); + this.addPresetButton(controls, "Reset window geometry", "reset", async () => { + if (this.onResetGeometry) { + await this.onResetGeometry(); + return; + } + + this.reset(); + await this.persistCurrentGeometry(); + }); + + return controls; + } + + private addPresetButton( + controls: HTMLElement, + label: string, + icon: keyof typeof ICON_DEFINITIONS, + onClick: () => void | Promise, + ): void { + const button = this.doc.createElement("button"); + button.type = "button"; + button.className = "setmove--preset-control"; + button.dataset.setmoveRole = "preset-control"; + button.setAttribute("aria-label", label); + button.append(this.createIcon(icon)); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void onClick(); + }); + controls.append(button); + } + + private createIcon(icon: keyof typeof ICON_DEFINITIONS): SVGSVGElement { + const iconDefinition: IconDefinition = ICON_DEFINITIONS[icon]; + const svg = this.doc.createElementNS(SVG_NAMESPACE, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + + for (const rectData of iconDefinition.fillRects ?? []) { + const rect = this.doc.createElementNS(SVG_NAMESPACE, "rect"); + rect.setAttribute("x", String(rectData.x)); + rect.setAttribute("y", String(rectData.y)); + rect.setAttribute("width", String(rectData.width)); + rect.setAttribute("height", String(rectData.height)); + rect.setAttribute("rx", "0.75"); + rect.setAttribute("fill", PRESET_ICON_COLOR); + rect.setAttribute("opacity", "0.85"); + svg.append(rect); + } + + for (const pathData of iconDefinition.fillPaths ?? []) { + const path = this.doc.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("d", pathData); + path.setAttribute("fill", PRESET_ICON_COLOR); + svg.append(path); + } + + for (const pathData of iconDefinition.paths ?? []) { + const path = this.doc.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("d", pathData); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", PRESET_ICON_COLOR); + path.setAttribute("stroke-width", "1.8"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.append(path); + } + + return svg; + } + + private createResizeHandle(): HTMLButtonElement { + const handle = this.doc.createElement("button"); + handle.type = "button"; + handle.className = RESIZE_HANDLE_CLASS; + handle.dataset.setmoveRole = "resize-handle"; + handle.setAttribute("aria-label", "Resize settings window"); + return handle; + } + + private syncHandleState( + handleEl: HTMLElement, + isVisible: boolean, + cursor: string, + ): void { + handleEl.hidden = !isVisible; + handleEl.style.display = isVisible ? "" : "none"; + handleEl.style.pointerEvents = isVisible ? "" : "none"; + handleEl.style.cursor = isVisible ? cursor : "default"; + } + + private createSession( + mode: "drag" | "resize", + handleEl: HTMLElement, + ): DragResizeSession { + const interactionEl = mode === "drag" ? this.modalEl : handleEl; + + return createDragResizeSession({ + isEnabled: () => + this.enabled && + (mode === "drag" ? this.settings.movable : this.settings.resizable), + handleEl: interactionEl, + modalEl: this.modalEl, + mode, + canStart: (target) => + mode === "drag" ? this.canStartDragFrom(target) : this.canStartResizeFrom(target), + getCurrentRect: () => this.getCurrentRect(), + getHostBounds: () => this.getHostBounds(), + onUpdate: (rect) => { + this.currentRect = rect; + this.applyRect(rect); + }, + onCommit: (rect) => { + this.currentRect = rect; + void this.persistGeometry(rect); + }, + }); + } + + private canStartDragFrom(target: Element): boolean { + if (target.closest("[data-setmove-role='drag-handle']")) { + return true; + } + + if (target.closest(NON_DRAGGABLE_SELECTOR)) { + return false; + } + + if (this.kind === "catalog" && target.closest(CATALOG_NON_DRAGGABLE_SELECTOR)) { + return false; + } + + return target.closest(ENHANCEABLE_MODAL_SELECTOR) === this.modalEl; + } + + private canStartResizeFrom(target: Element): boolean { + return Boolean(target.closest("[data-setmove-role='resize-handle']")); + } + + private async persistGeometry(rect: ModalRect): Promise { + if (!this.settings.rememberGeometry || !this.onGeometryPersist) { + return; + } + + await this.onGeometryPersist({ + schemaVersion: 1, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + lastAppliedBounds: this.getHostBounds(), + }); + } + + private captureInlineStyles(): InlineStyleSnapshot { + return { + height: this.modalEl.style.height, + left: this.modalEl.style.left, + maxHeight: this.modalEl.style.maxHeight, + maxWidth: this.modalEl.style.maxWidth, + position: this.modalEl.style.position, + top: this.modalEl.style.top, + width: this.modalEl.style.width, + }; + } + + private restoreInlineStyles(): void { + this.modalEl.style.height = this.styleSnapshot.height; + this.modalEl.style.left = this.styleSnapshot.left; + this.modalEl.style.maxHeight = this.styleSnapshot.maxHeight; + this.modalEl.style.maxWidth = this.styleSnapshot.maxWidth; + this.modalEl.style.position = this.styleSnapshot.position; + this.modalEl.style.top = this.styleSnapshot.top; + this.modalEl.style.width = this.styleSnapshot.width; + } +} + +export function enhanceSettingsModal( + match: SettingsModalMatch, + options: SettingsModalEnhancerOptions, +): SettingsModalEnhancer { + const existing = ENHANCER_BY_MODAL.get(match.modalEl); + if (existing) { + existing.updateSettings(options.settings); + return existing; + } + + const enhancer = new SettingsModalEnhancer(match, options); + ENHANCER_BY_MODAL.set(match.modalEl, enhancer); + return enhancer; +} diff --git a/src/settings-tab.ts b/src/settings-tab.ts new file mode 100644 index 0000000..524c969 --- /dev/null +++ b/src/settings-tab.ts @@ -0,0 +1,90 @@ +import { PluginSettingTab, Setting } from "obsidian"; +import type SetmovePlugin from "./main"; + +export class SetmoveSettingTab extends PluginSettingTab { + private readonly pluginRef: SetmovePlugin; + + constructor(plugin: SetmovePlugin) { + super(plugin.app, plugin); + this.pluginRef = plugin; + } + + display(): void { + const { containerEl } = this; + const plugin = this.pluginRef; + + containerEl.empty(); + containerEl.createEl("h2", { + cls: "setmove--settings-title", + text: "Settings Float", + }); + containerEl.createEl("p", { + cls: "setmove--settings-description", + text: "Move and resize Obsidian Settings and catalog dialogs so you can adjust options while keeping your notes and workspace visible.", + }); + + new Setting(containerEl) + .setName("Enable movable dialogs") + .setDesc("Allow supported Settings and catalog dialogs to be dragged from safe empty space.") + .addToggle((toggle) => + toggle + .setValue(plugin.settings.movable) + .onChange(async (value) => { + await plugin.updateSettings({ movable: value }); + }), + ); + + new Setting(containerEl) + .setName("Enable resizable dialogs") + .setDesc("Allow supported Settings and catalog dialogs to be resized from the bottom-right handle.") + .addToggle((toggle) => + toggle + .setValue(plugin.settings.resizable) + .onChange(async (value) => { + await plugin.updateSettings({ resizable: value }); + }), + ); + + new Setting(containerEl) + .setName("Remember window geometry") + .setDesc("Persist the last valid Settings position and size for this vault.") + .addToggle((toggle) => + toggle + .setValue(plugin.settings.rememberGeometry) + .onChange(async (value) => { + await plugin.updateSettings({ rememberGeometry: value }); + }), + ); + + new Setting(containerEl) + .setName("Disable on narrow windows") + .setDesc("Keep the plugin conservative on cramped desktop windows.") + .addToggle((toggle) => + toggle + .setValue(plugin.settings.disableOnNarrowWindows) + .onChange(async (value) => { + await plugin.updateSettings({ disableOnNarrowWindows: value }); + }), + ); + + new Setting(containerEl) + .setName("Disable on mobile") + .setDesc("Leave mobile behavior as a no-op for this release.") + .addToggle((toggle) => + toggle + .setValue(plugin.settings.disableOnMobile) + .onChange(async (value) => { + await plugin.updateSettings({ disableOnMobile: value }); + }), + ); + + new Setting(containerEl) + .setName("Reset saved geometry") + .setDesc("Forget the saved position and size so Settings reopens with the default layout.") + .addButton((button) => + button.setButtonText("Reset").onClick(async () => { + await plugin.resetSavedGeometryCommand(); + }), + ); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..cbf535a --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,217 @@ +export const SETTINGS_SCHEMA_VERSION = 1; +export const GEOMETRY_SCHEMA_VERSION = 1; + +export interface GeometryBoundsMetadata { + width: number; + height: number; +} + +export interface PersistedGeometry { + schemaVersion: typeof GEOMETRY_SCHEMA_VERSION; + x: number; + y: number; + width: number; + height: number; + lastAppliedBounds: GeometryBoundsMetadata; +} + +export interface SetmoveSettings { + schemaVersion: typeof SETTINGS_SCHEMA_VERSION; + movable: boolean; + resizable: boolean; + rememberGeometry: boolean; + showPresetControls: boolean; + disableOnMobile: boolean; + disableOnNarrowWindows: boolean; + narrowWindowThreshold: number; + geometry: PersistedGeometry | null; +} + +export interface PluginDataStore { + loadData(): Promise; + saveData(data: SetmoveSettings): Promise; +} + +const DEFAULT_NARROW_WINDOW_THRESHOLD = 720; + +export const DEFAULT_SETTINGS: SetmoveSettings = Object.freeze({ + schemaVersion: SETTINGS_SCHEMA_VERSION, + movable: true, + resizable: true, + rememberGeometry: true, + showPresetControls: true, + disableOnMobile: true, + disableOnNarrowWindows: true, + narrowWindowThreshold: DEFAULT_NARROW_WINDOW_THRESHOLD, + geometry: null, +}); + +type RecordLike = Record; + +export function migrateSettingsData(data: unknown): SetmoveSettings { + if (!isRecord(data)) { + return cloneSettings(DEFAULT_SETTINGS); + } + + return { + schemaVersion: SETTINGS_SCHEMA_VERSION, + movable: readBoolean(data.movable, DEFAULT_SETTINGS.movable), + resizable: readBoolean(data.resizable, DEFAULT_SETTINGS.resizable), + rememberGeometry: readBoolean( + data.rememberGeometry, + DEFAULT_SETTINGS.rememberGeometry, + ), + showPresetControls: readBoolean( + data.showPresetControls, + DEFAULT_SETTINGS.showPresetControls, + ), + disableOnMobile: readBoolean( + data.disableOnMobile, + DEFAULT_SETTINGS.disableOnMobile, + ), + disableOnNarrowWindows: readBoolean( + data.disableOnNarrowWindows, + DEFAULT_SETTINGS.disableOnNarrowWindows, + ), + narrowWindowThreshold: readPositiveNumber( + data.narrowWindowThreshold, + DEFAULT_SETTINGS.narrowWindowThreshold, + ), + geometry: parseGeometry(data.geometry), + }; +} + +export async function loadSettings( + dataStore: PluginDataStore, +): Promise { + return migrateSettingsData(await dataStore.loadData()); +} + +export async function saveSettings( + dataStore: PluginDataStore, + settings: SetmoveSettings, +): Promise { + await dataStore.saveData(serializeSettings(settings)); +} + +export function clearSavedGeometry(settings: SetmoveSettings): SetmoveSettings { + return { + ...serializeSettings(settings), + geometry: null, + }; +} + +export async function resetSavedGeometry( + dataStore: PluginDataStore, + settings: SetmoveSettings, +): Promise { + const nextSettings = clearSavedGeometry(settings); + + await saveSettings(dataStore, nextSettings); + return nextSettings; +} + +export function serializeSettings(settings: SetmoveSettings): SetmoveSettings { + return { + schemaVersion: SETTINGS_SCHEMA_VERSION, + movable: settings.movable, + resizable: settings.resizable, + rememberGeometry: settings.rememberGeometry, + showPresetControls: settings.showPresetControls, + disableOnMobile: settings.disableOnMobile, + disableOnNarrowWindows: settings.disableOnNarrowWindows, + narrowWindowThreshold: settings.narrowWindowThreshold, + geometry: settings.geometry ? cloneGeometry(settings.geometry) : null, + }; +} + +function parseGeometry(value: unknown): PersistedGeometry | null { + if (!isRecord(value)) { + return null; + } + + const x = readFiniteNumber(value.x); + const y = readFiniteNumber(value.y); + const width = readPositiveNumber(value.width); + const height = readPositiveNumber(value.height); + const lastAppliedBounds = parseBoundsMetadata(value.lastAppliedBounds); + + if ( + x === null || + y === null || + width === null || + height === null || + lastAppliedBounds === null + ) { + return null; + } + + return { + schemaVersion: GEOMETRY_SCHEMA_VERSION, + x, + y, + width, + height, + lastAppliedBounds, + }; +} + +function parseBoundsMetadata(value: unknown): GeometryBoundsMetadata | null { + if (!isRecord(value)) { + return null; + } + + const width = readPositiveNumber(value.width); + const height = readPositiveNumber(value.height); + + if (width === null || height === null) { + return null; + } + + return { width, height }; +} + +function cloneSettings(settings: SetmoveSettings): SetmoveSettings { + return serializeSettings(settings); +} + +function cloneGeometry(geometry: PersistedGeometry): PersistedGeometry { + return { + schemaVersion: GEOMETRY_SCHEMA_VERSION, + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + lastAppliedBounds: { + width: geometry.lastAppliedBounds.width, + height: geometry.lastAppliedBounds.height, + }, + }; +} + +function isRecord(value: unknown): value is RecordLike { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readPositiveNumber(value: unknown, fallback: number): number; +function readPositiveNumber(value: unknown): number | null; +function readPositiveNumber( + value: unknown, + fallback?: number, +): number | null { + const numberValue = readFiniteNumber(value); + + if (numberValue !== null && numberValue > 0) { + return numberValue; + } + + return fallback ?? null; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2de0598 --- /dev/null +++ b/styles.css @@ -0,0 +1,185 @@ +.vertical-tab-content .setmove--settings-title { + margin-left: 0; + margin-inline-start: 0; + padding-left: 0; + padding-inline-start: 0; +} + +.setmove--settings-description { + max-width: 640px; + margin: 0 0 1.5em; + color: var(--text-muted); + line-height: var(--line-height-normal); +} + +.setmove--settings-modal { + position: relative; + overflow: visible; + border: 1px solid var(--background-modifier-border); + box-shadow: var(--shadow-l); + cursor: grab; +} + +.setmove--settings-modal .setmove--settings-content { + min-height: 0; + overflow: auto; +} + +.setmove--drag-handle { + display: none; +} + +.setmove--drag-handle:hover { + display: none; +} + +.setmove--resize-handle { + position: absolute; + right: -1px; + bottom: -1px; + display: grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 1px solid var(--background-modifier-border); + border-right: 0; + border-bottom: 0; + border-top-left-radius: 10px; + border-top-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: inherit; + background: linear-gradient(135deg, + color-mix(in srgb, var(--background-secondary) 82%, transparent) 0%, + var(--background-secondary) 72%); + box-shadow: none; + color: #b8b8b8; + cursor: nwse-resize; + z-index: 2; +} + +.setmove--resize-handle::before { + content: ""; + width: 12px; + height: 12px; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cg stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Cline x1='1.5' y1='10.5' x2='10.5' y2='1.5'/%3E%3Cline x1='5' y1='10.5' x2='10.5' y2='5'/%3E%3Cline x1='8' y1='10.5' x2='10.5' y2='8'/%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cg stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Cline x1='1.5' y1='10.5' x2='10.5' y2='1.5'/%3E%3Cline x1='5' y1='10.5' x2='10.5' y2='5'/%3E%3Cline x1='8' y1='10.5' x2='10.5' y2='8'/%3E%3C/g%3E%3C/svg%3E"); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 12px 12px; + mask-size: 12px 12px; + opacity: 0.85; +} + +.setmove--resize-handle:hover { + border-color: var(--interactive-accent-hover); + background: linear-gradient(135deg, + color-mix(in srgb, var(--background-secondary-alt) 70%, var(--interactive-accent-hover) 30%) 0%, + var(--background-secondary-alt) 72%); + color: #a9a9a9; +} + +.setmove--settings-modal.setmove--is-interacting { + box-shadow: 0 0 0 1px var(--interactive-accent), var(--shadow-l); +} + +.setmove--settings-modal.setmove--is-dragging, +.setmove--settings-modal.setmove--is-dragging * { + cursor: grabbing; +} + +.setmove--settings-modal.setmove--is-resizing, +.setmove--settings-modal.setmove--is-resizing * { + cursor: nwse-resize; +} + +.setmove--settings-modal input, +.setmove--settings-modal textarea, +.setmove--settings-modal select, +.setmove--settings-modal button, +.setmove--settings-modal a[href], +.setmove--settings-modal [contenteditable="true"], +.setmove--settings-modal .setting-item, +.setmove--settings-modal .setting-item-control, +.setmove--settings-modal .vertical-tab-nav-item, +.setmove--settings-modal .clickable-icon, +.setmove--settings-modal .slider, +.setmove--settings-modal .dropdown { + cursor: auto; +} + +.setmove--settings-modal button, +.setmove--settings-modal a[href], +.setmove--settings-modal .clickable-icon, +.setmove--settings-modal .vertical-tab-nav-item { + cursor: pointer; +} + +.setmove--settings-modal .setmove--resize-handle { + cursor: nwse-resize; +} + +.setmove--preset-controls { + position: absolute; + z-index: 3; + display: flex; + gap: 4px; + align-items: center; + padding: 3px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: color-mix(in srgb, var(--background-secondary) 92%, transparent); + box-shadow: var(--shadow-s); + opacity: 0; + pointer-events: auto; + transition: opacity 120ms ease; +} + +.setmove--preset-controls:hover, +.setmove--preset-controls:focus-within { + opacity: 1; +} + +.setmove--preset-controls-top-right-horizontal { + top: 10px; + right: 42px; + flex-direction: row; + display: none; +} + +.setmove--preset-controls-bottom-right-horizontal { + bottom: 10px; + right: 42px; + flex-direction: row; +} + +.setmove--preset-controls-bottom-left-vertical { + bottom: 52px; + left: 10px; + flex-direction: column; + display: none; +} + +.setmove--preset-control { + display: grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: #777799; + cursor: pointer; +} + +.setmove--preset-control:hover { + background: var(--background-modifier-hover); + color: #777799; +} + +.setmove--preset-control svg { + width: 15px; + height: 15px; +} diff --git a/tests/drag-resize.test.ts b/tests/drag-resize.test.ts new file mode 100644 index 0000000..80d7056 --- /dev/null +++ b/tests/drag-resize.test.ts @@ -0,0 +1,453 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDragResizeSession } from "../src/drag-resize"; +import { enhanceSettingsModal } from "../src/modal-enhancer"; +import { DEFAULT_SETTINGS } from "../src/settings"; + +describe("drag and resize sessions", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("updates during drag, commits on pointerup, and toggles interaction state", () => { + const handleEl = document.createElement("div"); + const modalEl = document.createElement("div"); + const onUpdate = vi.fn(); + const onCommit = vi.fn(); + const onStateChange = vi.fn(); + stubPointerCapture(handleEl); + + const session = createDragResizeSession({ + isEnabled: () => true, + handleEl, + modalEl, + mode: "drag", + canStart: () => true, + getCurrentRect: () => ({ + x: 100, + y: 120, + width: 900, + height: 640, + }), + getHostBounds: () => ({ + x: 0, + y: 0, + width: 1440, + height: 900, + }), + onUpdate, + onCommit, + onStateChange, + }); + + handleEl.dispatchEvent(pointerEvent("pointerdown", { pointerId: 7, clientX: 20, clientY: 30 })); + expect(modalEl.classList.contains("setmove--is-dragging")).toBe(true); + handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 7, clientX: 70, clientY: 90 })); + handleEl.dispatchEvent(pointerEvent("pointerup", { pointerId: 7, clientX: 70, clientY: 90 })); + + expect(onStateChange).toHaveBeenNthCalledWith(1, true); + expect(onUpdate).toHaveBeenLastCalledWith({ + x: 150, + y: 180, + width: 900, + height: 640, + }); + expect(onCommit).toHaveBeenCalledWith({ + x: 150, + y: 180, + width: 900, + height: 640, + }); + expect(onStateChange).toHaveBeenLastCalledWith(false); + expect(modalEl.classList.contains("setmove--is-interacting")).toBe(false); + expect(modalEl.classList.contains("setmove--is-dragging")).toBe(false); + + session.destroy(); + }); + + it("ignores secondary buttons and cancels back to the starting rect", () => { + const handleEl = document.createElement("div"); + handleEl.dataset.setmoveRole = "resize-handle"; + const modalEl = document.createElement("div"); + const onUpdate = vi.fn(); + const onCommit = vi.fn(); + stubPointerCapture(handleEl); + + const session = createDragResizeSession({ + isEnabled: () => true, + handleEl, + modalEl, + mode: "resize", + canStart: () => true, + getCurrentRect: () => ({ + x: 20, + y: 30, + width: 900, + height: 640, + }), + getHostBounds: () => ({ + x: 0, + y: 0, + width: 1000, + height: 700, + }), + onUpdate, + onCommit, + }); + + handleEl.dispatchEvent(pointerEvent("pointerdown", { button: 2, pointerId: 2, clientX: 10, clientY: 10 })); + handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 2, clientX: 50, clientY: 50 })); + expect(onUpdate).not.toHaveBeenCalled(); + + handleEl.dispatchEvent(pointerEvent("pointerdown", { pointerId: 3, clientX: 10, clientY: 10 })); + expect(modalEl.classList.contains("setmove--is-resizing")).toBe(true); + handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 3, clientX: 60, clientY: 70 })); + handleEl.dispatchEvent(pointerEvent("pointercancel", { pointerId: 3, clientX: 60, clientY: 70 })); + + expect(onUpdate).toHaveBeenLastCalledWith({ + x: 20, + y: 30, + width: 900, + height: 640, + }); + expect(modalEl.classList.contains("setmove--is-resizing")).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + + session.destroy(); + }); +}); + +describe("modal enhancer interactions", () => { + beforeEach(() => { + document.body.innerHTML = ""; + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1440, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 900, + }); + }); + + it("persists geometry after drag ends and ignores disabled drag", () => { + const match = createSettingsFixture(); + const onGeometryPersist = vi.fn(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + onGeometryPersist, + }); + + const dragHandle = match.modalEl.querySelector('[data-setmove-role="drag-handle"]'); + if (!dragHandle) { + throw new Error("Missing drag handle"); + } + stubPointerCapture(dragHandle); + + dragHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 9, clientX: 10, clientY: 10 })); + dragHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 9, clientX: 110, clientY: 90 })); + dragHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 9, clientX: 110, clientY: 90 })); + + expect(match.modalEl.style.left).toBe("370px"); + expect(match.modalEl.style.top).toBe("210px"); + expect(onGeometryPersist).toHaveBeenCalledTimes(1); + expect(onGeometryPersist.mock.calls[0]?.[0]).toMatchObject({ + x: 370, + y: 210, + width: 900, + height: 640, + }); + + enhancer.updateSettings({ movable: false }); + dragHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 10, clientX: 10, clientY: 10 })); + dragHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 10, clientX: 200, clientY: 200 })); + dragHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 10, clientX: 200, clientY: 200 })); + + expect(onGeometryPersist).toHaveBeenCalledTimes(1); + + enhancer.destroy(); + }); + + it("allows dragging from whitespace but not from setting rows", () => { + const match = createSettingsFixture({ + extraContent: ` +
+
+
+
+ +
+ `, + }); + const onGeometryPersist = vi.fn(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + onGeometryPersist, + }); + + stubPointerCapture(match.modalEl); + + match.contentEl.dispatchEvent( + pointerEvent("pointerdown", { pointerId: 20, clientX: 10, clientY: 10 }), + ); + match.contentEl.dispatchEvent( + pointerEvent("pointermove", { pointerId: 20, clientX: 110, clientY: 90 }), + ); + match.contentEl.dispatchEvent( + pointerEvent("pointerup", { pointerId: 20, clientX: 110, clientY: 90 }), + ); + + expect(match.modalEl.style.left).toBe("370px"); + expect(match.modalEl.style.top).toBe("210px"); + + const nestedWhitespace = match.modalEl.querySelector(".settings-view-header"); + if (!nestedWhitespace) { + throw new Error("Missing nested whitespace"); + } + + nestedWhitespace.dispatchEvent( + pointerEvent("pointerdown", { pointerId: 22, clientX: 20, clientY: 20 }), + ); + nestedWhitespace.dispatchEvent( + pointerEvent("pointermove", { pointerId: 22, clientX: 70, clientY: 70 }), + ); + nestedWhitespace.dispatchEvent( + pointerEvent("pointerup", { pointerId: 22, clientX: 70, clientY: 70 }), + ); + + expect(match.modalEl.style.left).toBe("420px"); + expect(match.modalEl.style.top).toBe("260px"); + + const settingButton = match.modalEl.querySelector(".clickable-icon"); + if (!settingButton) { + throw new Error("Missing setting button"); + } + + const persistedCalls = onGeometryPersist.mock.calls.length; + settingButton.dispatchEvent( + pointerEvent("pointerdown", { pointerId: 21, clientX: 20, clientY: 20 }), + ); + settingButton.dispatchEvent( + pointerEvent("pointermove", { pointerId: 21, clientX: 200, clientY: 200 }), + ); + settingButton.dispatchEvent( + pointerEvent("pointerup", { pointerId: 21, clientX: 200, clientY: 200 }), + ); + + expect(onGeometryPersist).toHaveBeenCalledTimes(persistedCalls); + + enhancer.destroy(); + }); + + it("persists geometry after resize and clamps it into the host bounds", () => { + const match = createSettingsFixture(); + const onGeometryPersist = vi.fn(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + onGeometryPersist, + }); + + const resizeHandle = match.modalEl.querySelector('[data-setmove-role="resize-handle"]'); + if (!resizeHandle) { + throw new Error("Missing resize handle"); + } + stubPointerCapture(resizeHandle); + + resizeHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 11, clientX: 10, clientY: 10 })); + resizeHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 11, clientX: 800, clientY: 800 })); + resizeHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 11, clientX: 800, clientY: 800 })); + + expect(match.modalEl.style.width).toBe("1440px"); + expect(match.modalEl.style.height).toBe("900px"); + expect(onGeometryPersist).toHaveBeenCalledTimes(1); + + enhancer.destroy(); + }); + + it("allows catalog header dragging without hijacking catalog cards", () => { + const match = createCatalogFixture(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + geometry: null, + rememberGeometry: false, + }, + }); + + stubPointerCapture(match.modalEl); + + const header = match.modalEl.querySelector(".catalog-header"); + const card = match.modalEl.querySelector(".community-item"); + if (!header || !card) { + throw new Error("Missing catalog fixture elements"); + } + + header.dispatchEvent( + pointerEvent("pointerdown", { pointerId: 30, clientX: 10, clientY: 10 }), + ); + header.dispatchEvent( + pointerEvent("pointermove", { pointerId: 30, clientX: 110, clientY: 90 }), + ); + header.dispatchEvent( + pointerEvent("pointerup", { pointerId: 30, clientX: 110, clientY: 90 }), + ); + + expect(match.modalEl.style.left).toBe("370px"); + expect(match.modalEl.style.top).toBe("210px"); + + card.dispatchEvent( + pointerEvent("pointerdown", { pointerId: 31, clientX: 10, clientY: 10 }), + ); + card.dispatchEvent( + pointerEvent("pointermove", { pointerId: 31, clientX: 210, clientY: 210 }), + ); + card.dispatchEvent( + pointerEvent("pointerup", { pointerId: 31, clientX: 210, clientY: 210 }), + ); + + expect(match.modalEl.style.left).toBe("370px"); + expect(match.modalEl.style.top).toBe("210px"); + + enhancer.destroy(); + }); +}); + +function stubPointerCapture(element: HTMLElement): void { + Object.defineProperty(element, "setPointerCapture", { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(element, "releasePointerCapture", { + configurable: true, + value: vi.fn(), + }); +} + +function pointerEvent( + type: string, + init: { button?: number; clientX?: number; clientY?: number; pointerId?: number }, +): Event { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: init.button ?? 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + }) as MouseEvent & { pointerId: number }; + Object.defineProperty(event, "pointerId", { + configurable: true, + value: init.pointerId ?? 1, + }); + return event; +} + +function createSettingsFixture(options?: { extraContent?: string }) { + document.body.innerHTML = ` + + `; + + const modalEl = document.querySelector(".modal.mod-settings"); + const containerEl = document.querySelector(".modal-container"); + const contentEl = document.querySelector(".vertical-tab-content-container"); + + if (!modalEl || !containerEl || !contentEl) { + throw new Error("Missing settings fixture."); + } + + Object.defineProperty(modalEl, "offsetWidth", { + configurable: true, + value: 900, + }); + Object.defineProperty(modalEl, "offsetHeight", { + configurable: true, + value: 640, + }); + modalEl.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 900, + height: 640, + top: 0, + right: 900, + bottom: 640, + left: 0, + toJSON() { + return {}; + }, + }) as DOMRect; + + return { + modalEl, + containerEl, + contentEl, + doc: document, + kind: "settings" as const, + win: window, + }; +} + +function createCatalogFixture() { + document.body.innerHTML = ` + + `; + + const modalEl = document.querySelector(".modal"); + const containerEl = document.querySelector(".modal-container"); + + if (!modalEl || !containerEl) { + throw new Error("Missing catalog fixture."); + } + + Object.defineProperty(modalEl, "offsetWidth", { + configurable: true, + value: 900, + }); + Object.defineProperty(modalEl, "offsetHeight", { + configurable: true, + value: 640, + }); + modalEl.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 900, + height: 640, + top: 0, + right: 900, + bottom: 640, + left: 0, + toJSON() { + return {}; + }, + }) as DOMRect; + + return { + modalEl, + containerEl, + contentEl: null, + doc: document, + kind: "catalog" as const, + win: window, + }; +} diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts new file mode 100644 index 0000000..d6006ce --- /dev/null +++ b/tests/geometry.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "vitest"; +import { + MIN_MODAL_HEIGHT, + MIN_MODAL_WIDTH, + applyGeometryPreset, + clampModalRect, + getCenteredRect, + getMinimumModalSize, + getRestoredGeometry, + measureHostBounds, + type HostBounds, +} from "../src/geometry"; +import type { PersistedGeometry } from "../src/settings"; + +const hostBounds: HostBounds = { + x: 0, + y: 0, + width: 1440, + height: 900, +}; + +const savedGeometry: PersistedGeometry = { + schemaVersion: 1, + x: 48, + y: 72, + width: 900, + height: 640, + lastAppliedBounds: { + width: 1440, + height: 900, + }, +}; + +describe("geometry measurement", () => { + it("measures host bounds from a viewport", () => { + expect( + measureHostBounds({ + innerWidth: 1280, + innerHeight: 720, + }), + ).toEqual({ + x: 0, + y: 0, + width: 1280, + height: 720, + }); + }); + + it("caps minimum size to the available host space", () => { + expect( + getMinimumModalSize({ + x: 0, + y: 0, + width: 500, + height: 360, + }), + ).toEqual({ + width: 500, + height: 360, + }); + }); +}); + +describe("geometry clamping", () => { + it("clamps size and position into the visible host", () => { + expect( + clampModalRect( + { + x: -200, + y: 700, + width: 1800, + height: 1000, + }, + hostBounds, + ), + ).toEqual({ + x: 0, + y: 0, + width: 1440, + height: 900, + }); + }); + + it("enforces minimum dimensions when the host allows them", () => { + expect( + clampModalRect( + { + x: 10, + y: 10, + width: 320, + height: 260, + }, + hostBounds, + ), + ).toEqual({ + x: 10, + y: 10, + width: MIN_MODAL_WIDTH, + height: MIN_MODAL_HEIGHT, + }); + }); + + it("reclamps geometry after a host resize", () => { + expect( + clampModalRect(savedGeometry, { + x: 0, + y: 0, + width: 820, + height: 640, + }), + ).toEqual({ + x: 0, + y: 0, + width: 820, + height: 640, + }); + }); +}); + +describe("geometry presets", () => { + it("centers while preserving the current size when it fits", () => { + expect( + getCenteredRect( + { + width: 900, + height: 640, + }, + hostBounds, + ), + ).toEqual({ + x: 270, + y: 130, + width: 900, + height: 640, + }); + }); + + it("calculates left and right dock presets predictably", () => { + expect( + applyGeometryPreset( + "dock-left", + { + width: 900, + height: 640, + }, + hostBounds, + ), + ).toEqual({ + x: 0, + y: 0, + width: 640, + height: 900, + }); + + expect( + applyGeometryPreset( + "dock-right", + { + width: 900, + height: 640, + }, + hostBounds, + ), + ).toEqual({ + x: 800, + y: 0, + width: 640, + height: 900, + }); + }); + + it("keeps dock presets as narrow as the modal allows on wide screens", () => { + const wideHostBounds = { + x: 0, + y: 0, + width: 2048, + height: 1200, + }; + + expect( + applyGeometryPreset( + "dock-left", + { + width: 1100, + height: 720, + }, + wideHostBounds, + ), + ).toEqual({ + x: 0, + y: 0, + width: 640, + height: 1200, + }); + + expect( + applyGeometryPreset( + "dock-right", + { + width: 1100, + height: 720, + }, + wideHostBounds, + ), + ).toEqual({ + x: 1408, + y: 0, + width: 640, + height: 1200, + }); + }); + + it("keeps the optional bottom-half preset inside bounds", () => { + expect( + applyGeometryPreset( + "bottom-half", + { + width: 900, + height: 640, + }, + hostBounds, + ), + ).toEqual({ + x: 0, + y: 450, + width: 1440, + height: 450, + }); + }); +}); + +describe("saved geometry restoration", () => { + it("restores valid saved geometry and reclamps it for the current host", () => { + expect( + getRestoredGeometry(savedGeometry, { + x: 0, + y: 0, + width: 1000, + height: 700, + }), + ).toEqual({ + x: 48, + y: 60, + width: 900, + height: 640, + }); + }); + + it("ignores saved geometry that is too small, non-finite, or otherwise incompatible", () => { + expect( + getRestoredGeometry( + { + ...savedGeometry, + width: 320, + }, + hostBounds, + ), + ).toBeNull(); + + expect( + getRestoredGeometry( + { + ...savedGeometry, + height: Number.POSITIVE_INFINITY, + } as PersistedGeometry, + hostBounds, + ), + ).toBeNull(); + + expect( + getRestoredGeometry( + { + ...savedGeometry, + width: 2000, + }, + hostBounds, + ), + ).toBeNull(); + }); +}); diff --git a/tests/modal-detector.test.ts b/tests/modal-detector.test.ts new file mode 100644 index 0000000..8dfb034 --- /dev/null +++ b/tests/modal-detector.test.ts @@ -0,0 +1,210 @@ +import { JSDOM } from "jsdom"; +import { describe, expect, it, vi } from "vitest"; +import { + SettingsModalLifecycle, + findSettingsModal, + findSettingsModals, + isSettingsModalElement, +} from "../src/modal-detector"; + +describe("settings modal detection", () => { + it("matches the core Settings modal shape", () => { + document.body.innerHTML = ` + + `; + + const match = findSettingsModal(document); + + expect(match).not.toBeNull(); + expect(match?.modalEl.classList.contains("mod-settings")).toBe(true); + expect(match?.contentEl?.classList.contains("vertical-tab-content-container")).toBe( + true, + ); + expect(match?.kind).toBe("settings"); + expect(match?.doc).toBe(document); + expect(match?.win).toBe(window); + }); + + it("matches theme and community plugin catalog browser dialogs", () => { + document.body.innerHTML = ` + + + `; + + const dialogs = findSettingsModals(document); + + expect(dialogs).toHaveLength(2); + expect(dialogs.map((dialog) => dialog.modalEl.id)).toEqual([ + "theme-browser", + "plugin-browser", + ]); + expect(dialogs.every((dialog) => dialog.kind === "catalog")).toBe(true); + expect(dialogs.every((dialog) => dialog.contentEl === null)).toBe(true); + }); + + it("matches catalog dialogs before the showing count is populated", () => { + document.body.innerHTML = ` + + `; + + const match = findSettingsModal(document); + + expect(match?.modalEl.id).toBe("theme-browser"); + expect(match?.kind).toBe("catalog"); + }); + + it("attaches to catalog dialogs when their text content updates after open", async () => { + document.body.innerHTML = ""; + const onAttach = vi.fn(); + const lifecycle = new SettingsModalLifecycle({ onAttach }); + + lifecycle.trackDocument(document); + + document.body.innerHTML = ` + + `; + + await Promise.resolve(); + expect(onAttach).toHaveBeenCalledTimes(0); + + const countEl = document.getElementById("count"); + if (!countEl) { + throw new Error("Missing count fixture."); + } + + countEl.textContent = "Showing 367 themes"; + await Promise.resolve(); + + expect(onAttach).toHaveBeenCalledTimes(1); + expect(onAttach.mock.calls[0]?.[0].kind).toBe("catalog"); + + lifecycle.stop(); + }); + + it("rejects nested child dialogs and ordinary modals", () => { + document.body.innerHTML = ` + + + + `; + + const dialogs = findSettingsModals(document); + const fontDialog = document.getElementById("font-dialog"); + const fakeSettings = document.getElementById("fake-settings"); + + expect(dialogs).toHaveLength(1); + expect(dialogs[0]?.modalEl.id).toBe("settings-modal"); + expect(isSettingsModalElement(fontDialog)).toBe(false); + expect(isSettingsModalElement(fakeSettings)).toBe(false); + }); +}); + +describe("settings modal lifecycle", () => { + it("attaches once and detaches when the modal closes", async () => { + document.body.innerHTML = ""; + const onAttach = vi.fn(() => vi.fn()); + const onDetach = vi.fn(); + const lifecycle = new SettingsModalLifecycle({ onAttach, onDetach }); + + lifecycle.trackDocument(document); + + document.body.innerHTML = ` + + `; + + await Promise.resolve(); + expect(onAttach).toHaveBeenCalledTimes(1); + + document + .querySelector(".modal.mod-settings") + ?.classList.add("is-still-the-same-modal"); + + await Promise.resolve(); + expect(onAttach).toHaveBeenCalledTimes(1); + + document.querySelector(".modal-container")?.remove(); + + await Promise.resolve(); + expect(onDetach).toHaveBeenCalledTimes(1); + + lifecycle.stop(); + }); + + it("uses the owning document and window for popout matches", async () => { + document.body.innerHTML = ""; + const popout = new JSDOM(``); + const onAttach = vi.fn(); + const lifecycle = new SettingsModalLifecycle({ onAttach }); + + popout.window.document.body.innerHTML = ` + + `; + + lifecycle.trackDocument(popout.window.document); + expect(onAttach).toHaveBeenCalledTimes(1); + expect(onAttach.mock.calls[0]?.[0].doc).toBe(popout.window.document); + expect(onAttach.mock.calls[0]?.[0].win).toBe(popout.window); + + lifecycle.stop(); + popout.window.close(); + }); +}); diff --git a/tests/modal-enhancer.test.ts b/tests/modal-enhancer.test.ts new file mode 100644 index 0000000..a983efa --- /dev/null +++ b/tests/modal-enhancer.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { enhanceSettingsModal } from "../src/modal-enhancer"; +import type { SettingsModalMatch } from "../src/modal-detector"; +import { DEFAULT_SETTINGS, type PersistedGeometry } from "../src/settings"; + +const savedGeometry: PersistedGeometry = { + schemaVersion: 1, + x: 48, + y: 72, + width: 900, + height: 640, + lastAppliedBounds: { + width: 1440, + height: 900, + }, +}; + +describe("settings modal enhancer", () => { + beforeEach(() => { + document.body.innerHTML = ""; + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1440, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 900, + }); + }); + + it("adds classes, handles, and centered geometry", () => { + const match = createSettingsMatch(); + + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + }); + + expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(true); + expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).not.toBeNull(); + expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).not.toBeNull(); + expect( + Array.from( + match.modalEl.querySelectorAll( + '[data-setmove-role="preset-controls"]', + ), + ).map((controls) => controls.dataset.setmovePlacement), + ).toEqual([ + "top-right-horizontal", + "bottom-right-horizontal", + "bottom-left-vertical", + ]); + expect(match.modalEl.querySelectorAll('[data-setmove-role="preset-control"]')).toHaveLength(9); + expect( + Array.from( + match.modalEl.querySelectorAll( + '[data-setmove-role="preset-controls"]', + ), + ).map((controls) => [controls.dataset.setmovePlacement, controls.hidden]), + ).toEqual([ + ["top-right-horizontal", true], + ["bottom-right-horizontal", false], + ["bottom-left-vertical", true], + ]); + expect( + Array.from( + match.modalEl.querySelectorAll( + '[data-setmove-placement="top-right-horizontal"] [data-setmove-role="preset-control"]', + ), + ).map((control) => control.getAttribute("aria-label")), + ).toEqual([ + "Dock window left", + "Dock window right", + "Reset window geometry", + ]); + expect(match.modalEl.style.position).toBe("fixed"); + expect(match.modalEl.style.left).toBe("270px"); + expect(match.modalEl.style.top).toBe("130px"); + expect(match.modalEl.style.width).toBe("900px"); + expect(match.modalEl.style.height).toBe("640px"); + + enhancer.destroy(); + }); + + it("restores remembered geometry and exposes preset methods", () => { + const match = createSettingsMatch(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + geometry: savedGeometry, + }, + }); + + expect(match.modalEl.style.left).toBe("48px"); + expect(match.modalEl.style.top).toBe("72px"); + + enhancer.dockRight(); + expect(match.modalEl.style.left).toBe("800px"); + expect(match.modalEl.style.top).toBe("0px"); + expect(match.modalEl.style.width).toBe("640px"); + expect(match.modalEl.style.height).toBe("900px"); + + enhancer.center(); + expect(match.modalEl.style.left).toBe("400px"); + expect(match.modalEl.style.top).toBe("0px"); + expect(match.modalEl.style.width).toBe("640px"); + expect(match.modalEl.style.height).toBe("900px"); + + enhancer.reset(); + expect(match.modalEl.style.left).toBe("270px"); + expect(match.modalEl.style.top).toBe("130px"); + expect(match.modalEl.style.width).toBe("900px"); + expect(match.modalEl.style.height).toBe("640px"); + + enhancer.destroy(); + }); + + it("reuses one enhancer per modal and does not duplicate handles", () => { + const match = createSettingsMatch(); + const first = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + }); + const second = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + movable: false, + }, + }); + + expect(second).toBe(first); + expect( + match.modalEl.querySelectorAll('[data-setmove-role="drag-handle"]'), + ).toHaveLength(1); + expect( + match.modalEl.querySelectorAll('[data-setmove-role="resize-handle"]'), + ).toHaveLength(1); + expect( + match.modalEl.querySelectorAll('[data-setmove-role="preset-controls"]'), + ).toHaveLength(3); + expect( + match.modalEl.querySelectorAll('[data-setmove-role="preset-control"]'), + ).toHaveLength(9); + expect( + (match.modalEl.querySelector('[data-setmove-role="drag-handle"]') as HTMLElement) + .hidden, + ).toBe(true); + expect( + (match.modalEl.querySelector('[data-setmove-role="resize-handle"]') as HTMLElement) + .style.display, + ).not.toBe("none"); + + first.destroy(); + }); + + it("applies preset button actions and routes reset through the callback", () => { + const match = createSettingsMatch(); + let resetCalls = 0; + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + onResetGeometry: () => { + resetCalls += 1; + }, + }); + + const bottomHorizontalControls = match.modalEl.querySelector( + '[data-setmove-placement="bottom-right-horizontal"]', + ); + const dockRightButton = bottomHorizontalControls?.querySelector( + '[aria-label="Dock window right"]', + ); + dockRightButton?.click(); + + expect(match.modalEl.style.left).toBe("800px"); + expect(match.modalEl.style.top).toBe("0px"); + + const resetButton = bottomHorizontalControls?.querySelector( + '[aria-label="Reset window geometry"]', + ); + resetButton?.click(); + + expect(resetCalls).toBe(1); + + enhancer.destroy(); + }); + + it("renders position icons as a consistent window-thirds family", () => { + const match = createSettingsMatch(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + }); + + const controls = match.modalEl.querySelector( + '[data-setmove-placement="bottom-right-horizontal"]', + ); + const leftIcon = controls?.querySelector( + '[aria-label="Dock window left"] svg', + ); + const rightIcon = controls?.querySelector( + '[aria-label="Dock window right"] svg', + ); + const resetIcon = controls?.querySelector( + '[aria-label="Reset window geometry"] svg', + ); + + expect(controls?.querySelector('[aria-label="Center window"]')).toBeNull(); + expect(leftIcon?.querySelector("rect")?.getAttribute("x")).toBe("4"); + expect(rightIcon?.querySelector("rect")?.getAttribute("x")).toBe("15"); + expect(leftIcon?.querySelectorAll("path")).toHaveLength(2); + expect(rightIcon?.querySelectorAll("path")).toHaveLength(2); + expect(resetIcon?.querySelector("path")?.getAttribute("fill")).toBe("#777799"); + expect(leftIcon?.querySelector("path")?.getAttribute("stroke")).toBe("#777799"); + expect(resetIcon?.querySelector("path")?.getAttribute("d")).toContain( + "H4.2V4.9z", + ); + + enhancer.destroy(); + }); + + it("fully hides the resize handle when resizing is disabled", () => { + const match = createSettingsMatch(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + }); + + enhancer.updateSettings({ resizable: false }); + + const resizeHandle = match.modalEl.querySelector( + '[data-setmove-role="resize-handle"]', + ); + + expect(resizeHandle?.hidden).toBe(true); + expect(resizeHandle?.style.display).toBe("none"); + expect(resizeHandle?.style.pointerEvents).toBe("none"); + expect(resizeHandle?.style.cursor).toBe("default"); + + enhancer.destroy(); + }); + + it("enhances catalog dialogs without settings content rewrites", () => { + const match = createCatalogMatch(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + geometry: null, + rememberGeometry: false, + }, + }); + + expect(enhancer.kind).toBe("catalog"); + expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(true); + expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).not.toBeNull(); + expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).not.toBeNull(); + expect(match.modalEl.querySelectorAll('[data-setmove-role="preset-controls"]')).toHaveLength(3); + expect(match.modalEl.querySelector(".setmove--settings-content")).toBeNull(); + + enhancer.destroy(); + }); + + it("reclamps current geometry when the host window shrinks", () => { + const match = createSettingsMatch(); + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + geometry: savedGeometry, + }, + }); + + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 820, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 640, + }); + + window.dispatchEvent(new Event("resize")); + + expect(match.modalEl.style.left).toBe("0px"); + expect(match.modalEl.style.top).toBe("0px"); + expect(match.modalEl.style.width).toBe("820px"); + expect(match.modalEl.style.height).toBe("640px"); + + enhancer.destroy(); + }); + + it("cleans up classes, styles, handles, and settings-shell state on destroy", () => { + const match = createSettingsMatch(); + match.modalEl.style.position = "relative"; + match.modalEl.style.left = "12px"; + const enhancer = enhanceSettingsModal(match, { + settings: { + ...DEFAULT_SETTINGS, + }, + }); + + enhancer.destroy(); + + expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(false); + expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).toBeNull(); + expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).toBeNull(); + expect(match.modalEl.querySelector('[data-setmove-role="preset-controls"]')).toBeNull(); + expect(match.modalEl.style.position).toBe("relative"); + expect(match.modalEl.style.left).toBe("12px"); + expect(match.contentEl.style.overflow).toBe(""); + }); +}); + +function createSettingsMatch(): SettingsModalMatch & { contentEl: HTMLElement } { + document.body.innerHTML = ` + + `; + + const modalEl = document.querySelector(".modal.mod-settings"); + const containerEl = document.querySelector(".modal-container"); + const contentEl = document.querySelector(".vertical-tab-content-container"); + + if (!modalEl || !containerEl || !contentEl) { + throw new Error("Missing settings modal fixture."); + } + + Object.defineProperty(modalEl, "offsetWidth", { + configurable: true, + value: 900, + }); + Object.defineProperty(modalEl, "offsetHeight", { + configurable: true, + value: 640, + }); + modalEl.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 900, + height: 640, + top: 0, + right: 900, + bottom: 640, + left: 0, + toJSON() { + return {}; + }, + }) as DOMRect; + + return { + modalEl, + containerEl, + contentEl, + doc: document, + kind: "settings", + win: window, + }; +} + +function createCatalogMatch(): SettingsModalMatch { + document.body.innerHTML = ` + + `; + + const modalEl = document.querySelector(".modal"); + const containerEl = document.querySelector(".modal-container"); + + if (!modalEl || !containerEl) { + throw new Error("Missing catalog modal fixture."); + } + + Object.defineProperty(modalEl, "offsetWidth", { + configurable: true, + value: 1100, + }); + Object.defineProperty(modalEl, "offsetHeight", { + configurable: true, + value: 720, + }); + modalEl.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 1100, + height: 720, + top: 0, + right: 1100, + bottom: 720, + left: 0, + toJSON() { + return {}; + }, + }) as DOMRect; + + return { + modalEl, + containerEl, + contentEl: null, + doc: document, + kind: "catalog", + win: window, + }; +} diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts new file mode 100644 index 0000000..06269f6 --- /dev/null +++ b/tests/scaffold.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import manifest from "../manifest.json"; +import versions from "../versions.json"; + +describe("scaffold metadata", () => { + it("keeps the plugin id stable", () => { + expect(manifest.id).toBe("settings-float"); + expect(manifest.name).toBe("Settings Float"); + }); + + it("maps the current version to the minimum supported Obsidian version", () => { + expect(versions[manifest.version as keyof typeof versions]).toBe( + manifest.minAppVersion, + ); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..99771bd --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SETTINGS, + clearSavedGeometry, + loadSettings, + migrateSettingsData, + resetSavedGeometry, + saveSettings, + serializeSettings, + type PluginDataStore, + type PersistedGeometry, + type SetmoveSettings, +} from "../src/settings"; + +class MemoryDataStore implements PluginDataStore { + constructor(private data: unknown = null) {} + + async loadData(): Promise { + return this.data; + } + + async saveData(data: SetmoveSettings): Promise { + this.data = data; + } + + peek(): unknown { + return this.data; + } +} + +const savedGeometry: PersistedGeometry = { + schemaVersion: 1, + x: 48, + y: 72, + width: 900, + height: 640, + lastAppliedBounds: { + width: 1440, + height: 900, + }, +}; + +describe("settings persistence", () => { + it("uses defaults when no saved data exists", () => { + expect(migrateSettingsData(null)).toEqual(DEFAULT_SETTINGS); + }); + + it("merges partial saved data with current defaults", () => { + expect( + migrateSettingsData({ + movable: false, + rememberGeometry: false, + geometry: savedGeometry, + }), + ).toEqual({ + ...DEFAULT_SETTINGS, + movable: false, + rememberGeometry: false, + geometry: savedGeometry, + }); + }); + + it("loads and saves a typed, unknown-free settings payload", async () => { + const dataStore = new MemoryDataStore({ + movable: false, + extraFutureField: "ignored", + geometry: { + ...savedGeometry, + unknownGeometryField: true, + }, + }); + + const settings = await loadSettings(dataStore); + await saveSettings(dataStore, settings); + + expect(dataStore.peek()).toEqual({ + ...DEFAULT_SETTINGS, + movable: false, + geometry: savedGeometry, + }); + expect(JSON.stringify(dataStore.peek())).not.toContain("extraFutureField"); + expect(JSON.stringify(dataStore.peek())).not.toContain( + "unknownGeometryField", + ); + }); + + it("clears saved geometry without changing unrelated settings", () => { + const settings = { + ...DEFAULT_SETTINGS, + movable: false, + resizable: false, + geometry: savedGeometry, + }; + + expect(clearSavedGeometry(settings)).toEqual({ + ...settings, + geometry: null, + }); + }); + + it("persists a geometry reset without clearing unrelated settings", async () => { + const dataStore = new MemoryDataStore(); + const settings = { + ...DEFAULT_SETTINGS, + showPresetControls: false, + disableOnNarrowWindows: false, + geometry: savedGeometry, + }; + + const nextSettings = await resetSavedGeometry(dataStore, settings); + + expect(nextSettings).toEqual({ + ...settings, + geometry: null, + }); + expect(dataStore.peek()).toEqual(nextSettings); + }); + + it("recovers from invalid saved values", () => { + expect( + migrateSettingsData({ + movable: "yes", + resizable: 0, + rememberGeometry: false, + showPresetControls: false, + disableOnMobile: false, + disableOnNarrowWindows: false, + narrowWindowThreshold: -1, + geometry: { + x: Number.NaN, + y: 40, + width: 0, + height: 500, + lastAppliedBounds: { + width: 1000, + height: Infinity, + }, + }, + }), + ).toEqual({ + ...DEFAULT_SETTINGS, + rememberGeometry: false, + showPresetControls: false, + disableOnMobile: false, + disableOnNarrowWindows: false, + geometry: null, + }); + }); + + it("serializes settings as a deep copy", () => { + const settings = { + ...DEFAULT_SETTINGS, + geometry: savedGeometry, + }; + + const serialized = serializeSettings(settings); + + expect(serialized).toEqual(settings); + expect(serialized).not.toBe(settings); + expect(serialized.geometry).not.toBe(settings.geometry); + }); +}); diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..bf5fd9d --- /dev/null +++ b/todo.md @@ -0,0 +1,254 @@ +--- +title: "feat: Add movable and resizable Obsidian Settings" +type: feat +date: 2026-06-12 +origin: docs/requirements/movable-resizable-settings-prd.md +--- + +# TODO: Movable and Resizable Obsidian Settings Plugin + +## Summary + +Build a TypeScript Obsidian community plugin that enhances the core Settings modal and selected catalog browser dialogs with safe move, resize, persistence, and recovery controls. The first release should stay focused on Settings plus theme/community plugin browsing so users can preview appearance changes against the active workspace without closing and reopening dialogs. + +## Scope + +In scope: + +- Scaffold a standard Obsidian plugin project. +- Detect the core Settings modal plus theme/community plugin browser dialogs, and enhance only those supported dialogs. +- Add explicit drag and resize affordances. +- Persist safe geometry per vault through plugin data. +- Clamp modal geometry to the visible Obsidian window. +- Provide a settings-tab reset action for saved Settings geometry. +- Add minimal plugin settings and user-facing documentation. +- Add focused automated tests for pure geometry/state logic and manual QA coverage for Obsidian DOM behavior. + +Out of scope: + +- Recreating Obsidian Settings as a custom pane. +- Opening Settings in a separate native Electron window. +- Patching private Obsidian internals or monkey-patching Settings constructors. +- Making every Obsidian modal movable. +- Handling child-dialog positioning beyond avoiding interference. +- Mobile support beyond safe disable/no-op behavior. + + +## Goal Mode Instructions + +If this file is used with `/goal`, the goal for a single run is NOT to finish this whole file. The goal for a single run is: + +> Complete all unchecked tasks in exactly one phase, report manual test instructions, and stop. + +Stopping after one phase is a successful completion of that run. Do not continue just because there are more unchecked phases below. + +## Rules + +1. Only work on ONE incomplete phase at a time. +2. Before editing code, identify the single phase you are about to complete. +3. Complete all unchecked tasks in the selected phase. +4. Do not inspect or implement later phases except to understand dependencies for the selected phase. +5. When the selected phase is fully coded and verified, mark only that phase's completed tasks as [x]. +6. After finishing the selected phase, STOP immediately and report: +- what changed +- tests/build results +- manual Obsidian test instructions +1. Do not commit immediately after finishing a phase. +2. Wait for the user to manually test in Obsidian. +3. Only create a git commit after the user explicitly says `continue`. +4. When the user says `continue`, first commit the completed phase with a descriptive commit message, then begin exactly one next incomplete phase. +5. Never include unfinished phase work in the commit. +6. If there are still incomplete phases after the selected phase is complete, leave them incomplete and stop anyway. +7. Use bun for package management + + + +## Key Technical Decisions + +- KTD1. Use the official Obsidian sample plugin shape: start from a conventional `src/main.ts`, `manifest.json`, `styles.css`, `package.json`, `tsconfig.json`, and build config so the repo follows community plugin expectations. +- KTD2. Attach behavior through DOM observation and public plugin lifecycle hooks: the Obsidian API exposes DOM utilities and `registerDomEvent`, but not a first-class Settings-window API, so all DOM selectors must be centralized and easy to update. +- KTD3. Store plugin configuration and geometry with `loadData` and `saveData`: Obsidian plugin data is written to `data.json` in the plugin folder, which is the right persistence layer for per-vault geometry. +- KTD4. Treat desktop as the first-class surface: use `Platform` checks to no-op on mobile and narrow windows by default. +- KTD5. Keep generic nested modals unmodified: the motivating font-picker workflow has a child dialog, so the modal detector must identify the main Settings modal and selected catalog browser dialogs while avoiding handles on unrelated child dialogs. +- KTD6. Keep geometry logic pure where possible: bounds, presets, clamping, and persistence validation should live in testable modules rather than inside pointer-event handlers. + +## Output Structure + +```text +. +├── manifest.json +├── package.json +├── package-lock.json +├── tsconfig.json +├── esbuild.config.mjs +├── eslint.config.mts +├── styles.css +├── versions.json +├── README.md +├── src/ +│ ├── main.ts +│ ├── settings.ts +│ ├── settings-tab.ts +│ ├── modal-detector.ts +│ ├── modal-enhancer.ts +│ ├── geometry.ts +│ ├── drag-resize.ts +│ ├── commands.ts +│ └── platform.ts +└── tests/ + ├── geometry.test.ts + ├── settings.test.ts + ├── modal-detector.test.ts + └── drag-resize.test.ts +``` + +## Phase 1: Project Scaffold + +- [x] Create the Obsidian plugin scaffold files: `manifest.json`, `package.json`, `tsconfig.json`, `esbuild.config.mjs`, `eslint.config.mts`, `versions.json`, `styles.css`, and `src/main.ts`. +- [x] Choose the plugin id and display name, keeping them stable for `manifest.json`, release assets, and documentation. +- [x] Configure TypeScript and esbuild to compile `src/main.ts` into root `main.js`. +- [x] Add package scripts for development build, production build, linting, type checking, and tests. +- [x] Add test tooling for TypeScript modules that can run outside Obsidian, such as Vitest with a DOM-capable environment. +- [x] Add `.gitignore` entries for build output, dependencies, editor files, and local vault/test artifacts. +- [x] Add a minimal `README.md` with install, development, and current limitation notes. +- [x] Verify the scaffold can build before feature work begins. + +## Phase 2: Settings Model and Persistence + +- [x] Create `src/settings.ts` with typed plugin settings for movable behavior, resizable behavior, persistence, visible preset controls, and mobile/narrow-window disablement. +- [x] Define a persisted geometry model with `x`, `y`, `width`, `height`, schema version, and last-applied bounds metadata. +- [x] Add defaults that match the PRD: movable on, resizable on, remember geometry on, preset controls on if visible controls ship, and mobile/narrow disablement on. +- [x] Implement loading settings through `loadData`, merging missing fields with defaults. +- [x] Implement saving settings through `saveData`, preserving forward-compatible unknown-free typed data. +- [x] Add geometry reset helpers that clear saved geometry without clearing unrelated plugin settings. +- [x] Add migration handling for future schema changes, even if version 0.1 only has one schema. +- [x] Test default merging, persistence round trips, geometry reset, and invalid saved-data recovery in `tests/settings.test.ts`. + +## Phase 3: Settings Modal Detection and Lifecycle + +- [x] Create `src/modal-detector.ts` to identify the core Settings modal through centralized selector and structure checks. +- [x] Prefer robust signals such as Settings-specific modal structure, title/sidebar/content shape, and exclusion of child dialogs over broad `.modal` matching. +- [x] Identify theme and community plugin browser dialogs through conservative catalog-browser structure checks. +- [x] Use a `MutationObserver` or lifecycle polling strategy to notice when Settings opens without patching Obsidian internals. +- [x] Ensure the detector returns no match for nested child dialogs such as font selectors. +- [x] Ensure the detector returns no match for ordinary plugin modals and non-Settings Obsidian dialogs. +- [x] Create an attach/detach lifecycle that enhances each Settings modal once and cleans up when it closes. +- [x] Handle Obsidian popout windows by using the modal's owning `document` and `window`, not only global `document` and `window`. +- [x] Route detector cleanup through plugin unload. +- [x] Test detector behavior with representative DOM fixtures in `tests/modal-detector.test.ts`. + +## Phase 4: Geometry Engine + +- [x] Create `src/geometry.ts` with pure functions for measuring host bounds, validating saved geometry, clamping rectangles, and applying presets. +- [x] Define minimum modal dimensions that keep Settings usable and record the rationale in code comments or docs. +- [x] Clamp `x` and `y` so at least the full modal, or a defined recovery-safe portion, remains inside the visible Obsidian window. +- [x] Clamp `width` and `height` between minimum dimensions and available host bounds. +- [x] Add preset calculations for center, dock left, dock right, and bottom half if bottom half is kept for 0.1. +- [x] Ensure persisted geometry is ignored when dimensions are non-finite, negative, too small, or incompatible with the current host window. +- [x] Recompute geometry on host window resize. +- [x] Preserve current modal size when centering, unless it no longer fits. +- [x] Keep geometry functions independent from Obsidian imports. +- [x] Test clamping, minimums, presets, bad persisted data, host resize, and edge cases in `tests/geometry.test.ts`. + +## Phase 5: Modal Enhancement Shell + +- [x] Create `src/modal-enhancer.ts` to own one enhanced Settings modal instance. +- [x] Add a plugin-specific class to the Settings modal while enhanced. +- [x] Add a drag handle in the modal header or an inserted handle strip. +- [x] Add a resize handle at the bottom-right corner. +- [x] Apply geometry through inline styles or CSS variables in a way that does not fight Obsidian themes. +- [x] Keep Settings content scrollable after resizing. +- [x] Make enhancement idempotent so reopening or DOM mutation does not duplicate handles. +- [x] Detach handles, classes, inline styles, observers, and event listeners on modal close. +- [x] Expose methods for commands to center, reset, dock left, dock right, and toggle behavior. +- [x] Add integration-shaped tests with DOM fixtures where practical, plus manual QA notes for behavior that requires Obsidian. + +## Phase 6: Drag and Resize Interactions + +- [x] Create `src/drag-resize.ts` for pointer-event state machines shared by drag and resize behavior. +- [x] Start drag only from the drag handle, never from setting rows, inputs, sliders, dropdowns, or buttons. +- [x] Start resize only from the resize handle. +- [x] Use pointer capture during active drag/resize when available. +- [x] Disable text selection or accidental interaction only for the duration of active drag/resize. +- [x] Convert pointer deltas into geometry changes through the pure geometry engine. +- [x] Persist geometry after interaction ends when persistence is enabled. +- [x] Ignore secondary mouse buttons and unsupported touch/mobile cases for 0.1. +- [x] Handle cancel paths such as pointer cancellation, modal close, and plugin unload. +- [x] Test pointer state transitions, ignored targets, persistence callbacks, and cancellation in `tests/drag-resize.test.ts`. + +## Phase 7: Commands, Presets, and Settings UI + +- [x] Create `src/commands.ts` to register reset, center, dock-left, dock-right, and toggle commands from the main plugin. +- [x] Ensure reset works even when Settings is closed by clearing saved geometry and applying default behavior next open. +- [x] Ensure commands gracefully no-op or show a notice when Settings is not currently open and the command only affects a live modal. +- [x] Create `src/settings-tab.ts` with toggles for movable, resizable, persistence, preset controls, and mobile/narrow disablement. +- [x] Add a reset saved geometry button to the settings tab. +- [x] Apply setting changes to the currently open Settings modal without requiring an Obsidian restart where feasible. +- [x] Keep visible preset controls optional; if shipped in 0.1, add them as compact controls that do not crowd the Settings UI. +- [x] Test command registration and settings-state behavior with mocks where practical. + +## Phase 8: Styling, Accessibility, and Theme Compatibility + +- [x] Add isolated CSS classes in `styles.css` for the enhanced modal, drag handle, resize handle, active-drag state, and optional preset controls. +- [x] Use Obsidian CSS variables for colors, borders, backgrounds, shadows, and text contrast. +- [x] Ensure handles are visible in light and dark themes. +- [x] Add accessible labels or titles for visible handles and preset controls. +- [x] Preserve Escape, close button, click-outside, and focus behavior for Settings and child dialogs. +- [x] Avoid layout styles that cause nested modals or Settings content to lose scrollability. +- [x] Manually verify common built-in themes and at least one custom theme before release. + +## Phase 9: Obsidian Manual QA + +- [ ] Install the built plugin into a test vault under `.obsidian/plugins//`. +- [ ] Verify Settings opens normally with the plugin disabled. +- [ ] Verify Settings opens with a drag affordance and resize affordance when the plugin is enabled. +- [ ] Verify Appearance font changes can be previewed with part of the active note visible. +- [ ] Verify dragging does not trigger or block Settings controls. +- [ ] Verify resizing keeps Settings content usable and scrollable. +- [ ] Verify geometry persists after closing and reopening Settings. +- [ ] Verify reset, center, dock-left, and dock-right commands. +- [ ] Verify the Text font child dialog does not receive handles and still closes/focuses correctly. +- [ ] Verify host window resize clamps the modal back into a recoverable visible area. + +## Phase 10: Documentation and Release Readiness + +- [ ] Expand `README.md` with screenshots or GIFs showing the appearance-preview workflow. +- [x] Document commands, settings, recovery behavior, and mobile/narrow-window limitations. +- [x] Document known compatibility risks around Obsidian DOM changes and themes. +- [x] Add release notes for version 0.1. +- [x] Confirm release artifacts are `manifest.json`, `main.js`, and `styles.css`. +- [x] Confirm `versions.json` maps the plugin version to the intended minimum Obsidian version. +- [x] Run final build, lint, type-check, and test verification. +- [x] Manually inspect the release bundle before publishing. + +## Acceptance Coverage Map + +| Acceptance criterion | Primary phases | +|---|---| +| AC1. Settings shows a draggable affordance | Phase 3, Phase 5, Phase 9 | +| AC2. Dragging moves Settings without triggering controls | Phase 6, Phase 8, Phase 9 | +| AC3. Resize changes dimensions while preserving content | Phase 4, Phase 5, Phase 9 | +| AC4. Reopen restores valid geometry | Phase 2, Phase 4, Phase 9 | +| AC5. Host resize clamps modal into view | Phase 4, Phase 5, Phase 9 | +| AC6. Reset restores default centered layout | Phase 2, Phase 7, Phase 9 | +| AC7. Dock presets produce predictable layouts | Phase 4, Phase 7, Phase 9 | +| AC8. Child font dialog does not receive handles | Phase 3, Phase 5, Phase 9 | +| AC9. Disabling plugin restores normal behavior | Phase 5, Phase 7, Phase 9 | +| AC10. Plugin unload cleans up listeners and DOM additions | Phase 3, Phase 5, Phase 6 | + +## Risks and Mitigations + +- Obsidian Settings DOM changes: centralize selectors in `src/modal-detector.ts`, keep behavior fail-closed, and document manual QA after Obsidian updates. +- Theme conflicts: use isolated class names and Obsidian CSS variables in `styles.css`. +- Lost modal geometry: validate persisted data, clamp on every open and resize, and ship a settings-tab reset action in 0.1. +- Nested modal interference: explicitly test child dialogs and keep enhancement scoped to the main Settings modal plus selected catalog browser dialogs. +- Pointer-event regressions: keep drag/resize state isolated in `src/drag-resize.ts` and cover state transitions with tests. +- Limited automated coverage inside Obsidian: test pure modules thoroughly and maintain an explicit manual QA checklist for Obsidian-only behavior. + +## Sources and References + +- `docs/requirements/movable-resizable-settings-prd.md` +- `docs/ideation/2026-06-12-movable-resizable-obsidian-settings-ideation.html` +- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin +- Obsidian API definitions: https://raw.githubusercontent.com/obsidianmd/obsidian-api/master/obsidian.d.ts +- Obsidian forum request: https://forum.obsidian.md/t/resizable-settings-window/36628 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..470624a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "ES2020", + "lib": ["DOM", "ES2020"], + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "esbuild.config.mjs", "eslint.config.mts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..603e6b7 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.6.0" +}