diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
new file mode 100644
index 00000000..8eb8c111
--- /dev/null
+++ b/.github/workflows/deploy-site.yml
@@ -0,0 +1,78 @@
+name: Deploy Static Site
+
+# Single source of truth for the gh-pages branch (served at
+# diagrams.mingrammer.com). Builds BOTH the Docusaurus docs and the
+# playground, assembles them into one tree (docs at the root, playground
+# under /playground/), and deploys the whole thing. Because everything is
+# rebuilt and published together, the docs and the playground can never
+# overwrite each other — this replaces the old manual `website/publish.sh`
+# and the separate playground deploy.
+#
+# Safety: the deploy step only runs if every build step succeeds, so a failed
+# build leaves the live gh-pages branch untouched.
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - "website/**"
+ - "playground/**"
+ - "diagrams/**"
+ - "resources/**"
+ - ".github/workflows/deploy-site.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: deploy-site
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ # --- Docs (Docusaurus v1) -> website/build/diagrams/ (includes CNAME) ---
+ - name: Build docs
+ working-directory: website
+ env:
+ # Docusaurus 1.x uses a legacy webpack/OpenSSL path that needs this
+ # on Node 17+.
+ NODE_OPTIONS: --openssl-legacy-provider
+ run: |
+ npm install --no-audit --no-fund
+ npm run build
+
+ # --- Playground (Pyodide assets + Vite) -> playground/dist/ ---
+ - name: Install diagrams (for asset generation)
+ run: pip install .
+ - name: Build playground
+ run: |
+ cd playground && npm ci && cd ..
+ python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public
+ cd playground && npm run build
+
+ # --- Assemble the combined site ---
+ - name: Assemble site
+ run: |
+ rm -rf _site
+ cp -a website/build/diagrams _site
+ mkdir -p _site/playground
+ cp -a playground/dist/. _site/playground/
+ test -f _site/CNAME || echo "diagrams.mingrammer.com" > _site/CNAME
+
+ - name: Deploy to gh-pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: _site
+ force_orphan: true
diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml
new file mode 100644
index 00000000..caf4dd9e
--- /dev/null
+++ b/.github/workflows/playground.yml
@@ -0,0 +1,38 @@
+name: Playground
+
+on:
+ pull_request:
+ paths:
+ - "playground/**"
+ - "diagrams/**"
+ - "resources/**"
+ - ".github/workflows/playground.yml"
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - name: Install python deps
+ run: pip install . pytest
+ - name: Python tests (catalog + shim)
+ run: python3 -m pytest playground/scripts/ -v
+ - name: Generate assets
+ run: python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public
+ - name: npm install & unit tests & build
+ working-directory: playground
+ run: |
+ npm ci
+ npm test
+ npm run build
+ - name: E2E smoke
+ working-directory: playground
+ run: |
+ npx playwright install chromium --with-deps
+ npm run e2e
diff --git a/.isort.cfg b/.isort.cfg
index 8112570b..688c7715 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -2,4 +2,4 @@
line_length = 120
multi_line_output = 3
include_trailing_comma = True
-known_third_party = graphviz,jinja2
+known_third_party = graphviz,jinja2,pytest
diff --git a/README.md b/README.md
index 18ad5634..8309314f 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,8 @@ Diagrams lets you draw the cloud system architecture **in Python code**. It was
## Getting Started
+> Want to try it first? The [**Playground**](https://diagrams.mingrammer.com/playground/) runs **diagrams** right in your browser — no installation required.
+
It requires **Python 3.9** or higher, check your Python version first.
It uses [Graphviz](https://www.graphviz.org/) to render the diagram, so you need to [install Graphviz](https://graphviz.gitlab.io/download/) to use **diagrams**. After installing graphviz (or already have it), install the **diagrams**.
@@ -60,6 +62,12 @@ $ poetry add diagrams
You can start with [quick start](https://diagrams.mingrammer.com/docs/getting-started/installation#quick-start). Check out [guides](https://diagrams.mingrammer.com/docs/guides/diagram) for more details, and you can find all available nodes list in [here](https://diagrams.mingrammer.com/docs/nodes/aws).
+## Playground
+
+[**diagrams.mingrammer.com/playground**](https://diagrams.mingrammer.com/playground/)
+
+Write **diagrams** code and see the rendered diagram instantly, without installing anything. It runs the real **diagrams** package in your browser via [Pyodide](https://pyodide.org), and supports node search, autocompletion, PNG/SVG/JPEG export, and shareable links.
+
## Examples
| Event Processing | Stateful Architecture | Advanced Web Service |
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index cdbc86f8..3298ac1b 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -3,6 +3,8 @@ id: installation
title: Installation
---
+> Prefer to try it without installing? The [**Playground**](/playground/) runs **diagrams** in your browser.
+
**diagrams** requires **Python 3.7** or higher, check your Python version first.
**diagrams** uses [Graphviz](https://www.graphviz.org/) to render the diagram, so you need to [install Graphviz](https://graphviz.gitlab.io/download/) to use it.
diff --git a/playground/.gitignore b/playground/.gitignore
new file mode 100644
index 00000000..309652bb
--- /dev/null
+++ b/playground/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+dist/
+public/icons/
+public/wheels/
+public/catalog.json
+test-results/
+playwright-report/
+*.tsbuildinfo
+vite.config.js
+vite.config.d.ts
+.omc/
diff --git a/playground/e2e/playground.spec.ts b/playground/e2e/playground.spec.ts
new file mode 100644
index 00000000..c0603b12
--- /dev/null
+++ b/playground/e2e/playground.spec.ts
@@ -0,0 +1,57 @@
+import { expect, test } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ // wait for Pyodide init + the default example to finish rendering
+ await expect(page.getByTestId("preview").locator("svg")).toBeVisible({ timeout: 150_000 });
+});
+
+test("renders the default example with icons", async ({ page }) => {
+ const preview = page.getByTestId("preview");
+ await expect(preview.locator("svg image").first()).toHaveAttribute("xlink:href", /icons\/aws\//);
+});
+
+test("autocompletes EC2 from the aws compute module", async ({ page }) => {
+ const editor = page.getByTestId("editor").locator(".cm-content");
+ await editor.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.type("from diagrams.aws.compute import EC2A");
+ await expect(page.locator(".cm-tooltip-autocomplete")).toContainText("EC2AutoScaling", { timeout: 10_000 });
+});
+
+test("share link roundtrips code", async ({ page, context }) => {
+ const editor = page.getByTestId("editor").locator(".cm-content");
+ await editor.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.type('from diagrams import Diagram\nwith Diagram("Shared", show=False):\n pass');
+ await page.getByTestId("share-button").click();
+ await expect(page.getByTestId("share-button")).toContainText("Link copied!");
+ const url = page.url();
+ expect(url).toContain("#code=");
+ const second = await context.newPage();
+ await second.goto(url);
+ await expect(second.getByTestId("editor")).toContainText("Shared", { timeout: 150_000 });
+});
+
+test("python errors keep the previous preview", async ({ page }) => {
+ const editor = page.getByTestId("editor").locator(".cm-content");
+ await editor.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.type("1/0");
+ await expect(page.getByTestId("error-panel")).toContainText("ZeroDivisionError", { timeout: 30_000 });
+ await expect(page.getByTestId("preview").locator("svg")).toBeVisible();
+});
+
+test("exports PNG", async ({ page }) => {
+ const downloadPromise = page.waitForEvent("download");
+ // Background White is the ExportBar's default; WIDTH/HEIGHT left as
+ // "auto" fall back to the default-2x output size.
+ await page.getByRole("button", { name: "PNG" }).click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toMatch(/\.png$/);
+});
+
+test("copies image to clipboard", async ({ page }) => {
+ await page.getByRole("button", { name: "Copy Image" }).click();
+ await expect(page.getByText("Copied!")).toBeVisible();
+});
diff --git a/playground/index.html b/playground/index.html
new file mode 100644
index 00000000..ce335507
--- /dev/null
+++ b/playground/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Diagrams Playground
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/package-lock.json b/playground/package-lock.json
new file mode 100644
index 00000000..20b74ffe
--- /dev/null
+++ b/playground/package-lock.json
@@ -0,0 +1,3418 @@
+{
+ "name": "diagrams-playground",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "diagrams-playground",
+ "version": "0.1.0",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.0",
+ "@codemirror/commands": "^6.10.4",
+ "@codemirror/lang-python": "^6.1.6",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/state": "^6.4.1",
+ "@codemirror/view": "^6.34.0",
+ "@hpcc-js/wasm-graphviz": "^1.7.0",
+ "@lezer/highlight": "^1.2.3",
+ "codemirror": "^6.0.1",
+ "dompurify": "^3.1.6",
+ "pako": "^2.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.47.0",
+ "@testing-library/react": "^16.0.1",
+ "@types/pako": "^2.0.3",
+ "@types/react": "^18.3.5",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "jsdom": "^25.0.0",
+ "typescript": "~5.6.2",
+ "vite": "^5.4.3",
+ "vitest": "^2.1.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-python": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.3.2",
+ "@codemirror/language": "^6.8.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/python": "^1.1.4"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.42.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
+ "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@hpcc-js/wasm-graphviz": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.28.0.tgz",
+ "integrity": "sha512-KqvPy7ckxPc1xv8Kt7H3bo9cj8U4gfOXxQ8grLZvDZxyQQjnXsKxrweJLLJtEoWWtQqAk4biUB0g2qsjkTiMVA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/python": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
+ "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
+ "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.12",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
+ "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/dompurify": {
+ "version": "3.4.13",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
+ "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.400",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz",
+ "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "25.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.1.0",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.52",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz",
+ "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pako": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
+ "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
+ "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/playground/package.json b/playground/package.json
new file mode 100644
index 00000000..4734540d
--- /dev/null
+++ b/playground/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "diagrams-playground",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "assets": "cd .. && python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public",
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --port 4173",
+ "test": "vitest run",
+ "e2e": "playwright test"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.0",
+ "@codemirror/commands": "^6.10.4",
+ "@codemirror/lang-python": "^6.1.6",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/state": "^6.4.1",
+ "@codemirror/view": "^6.34.0",
+ "@lezer/highlight": "^1.2.3",
+ "@hpcc-js/wasm-graphviz": "^1.7.0",
+ "codemirror": "^6.0.1",
+ "dompurify": "^3.1.6",
+ "pako": "^2.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.47.0",
+ "@testing-library/react": "^16.0.1",
+ "@types/pako": "^2.0.3",
+ "@types/react": "^18.3.5",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "jsdom": "^25.0.0",
+ "typescript": "~5.6.2",
+ "vite": "^5.4.3",
+ "vitest": "^2.1.0"
+ }
+}
diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts
new file mode 100644
index 00000000..327f6e45
--- /dev/null
+++ b/playground/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "e2e",
+ timeout: 180_000, // pyodide 초기 로드 포함
+ // Conservative default: each spec cold-loads Pyodide (runtime + wheel
+ // install) in its own context, so parallel workers mean simultaneous
+ // large downloads/WASM boots competing for CPU and network — a known
+ // flakiness source on constrained CI runners, though parallel runs do
+ // pass locally. Serial trades wall-clock time for determinism.
+ fullyParallel: false,
+ workers: 1,
+ use: {
+ baseURL: "http://localhost:4173",
+ // Headless Chromium does not grant clipboard-write by default, so
+ // navigator.clipboard.writeText() rejects with NotAllowedError unless
+ // explicitly granted here (needed for the "share link" test's
+ // writeText().then(...) success path).
+ permissions: ["clipboard-read", "clipboard-write"],
+ },
+ webServer: {
+ command: "npm run preview",
+ port: 4173,
+ reuseExistingServer: true,
+ timeout: 30_000,
+ },
+});
diff --git a/playground/public/diagrams-logo.png b/playground/public/diagrams-logo.png
new file mode 100644
index 00000000..7a4de090
Binary files /dev/null and b/playground/public/diagrams-logo.png differ
diff --git a/playground/scripts/gen_catalog.py b/playground/scripts/gen_catalog.py
new file mode 100644
index 00000000..3f9dd592
--- /dev/null
+++ b/playground/scripts/gen_catalog.py
@@ -0,0 +1,120 @@
+"""Generate playground build assets from the diagrams package.
+
+Outputs (under --out):
+ catalog.json node classes / aliases / icons / constructor signatures
+ icons/** copy of resources/ for the preview tags
+ wheels/*.whl slim diagrams wheel (resources stripped; not needed at
+ runtime because Node._load_icon only builds path strings)
+ wheels/manifest.json {"wheel": ""} for the worker to locate it
+"""
+
+import argparse
+import importlib
+import inspect
+import json
+import pkgutil
+import shutil
+import subprocess
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+
+
+def build_catalog(repo_root: Path) -> dict:
+ sys.path.insert(0, str(repo_root))
+ import diagrams
+ from diagrams import Cluster, Diagram, Edge, Node
+
+ modules = {}
+ for info in pkgutil.walk_packages([str(repo_root / "diagrams")], prefix="diagrams."):
+ if any(part.startswith("_") for part in info.name.split(".")):
+ continue
+ mod = importlib.import_module(info.name)
+ classes, aliases = {}, {}
+ for attr, val in vars(mod).items():
+ if attr.startswith("_") or not inspect.isclass(val):
+ continue
+ if not issubclass(val, Node) or val.__module__ != info.name:
+ continue
+ if getattr(val, "_icon", None) is None:
+ continue
+ # Check if icon file exists on disk
+ icon_rel = "/".join([*Path(val._icon_dir).parts[1:], val._icon])
+ if not (repo_root / "resources" / icon_rel).exists():
+ print(
+ f"warning: skipping {info.name}.{val.__name__} — missing icon resources/{icon_rel}", file=sys.stderr
+ )
+ continue
+ if attr == val.__name__:
+ classes[attr] = val
+ else: # module-level alias assignment (e.g. ECS = ElasticContainerService)
+ aliases.setdefault(val.__name__, []).append(attr)
+ if classes:
+ modules[info.name] = [
+ {
+ "name": name,
+ "aliases": sorted(aliases.get(name, [])),
+ # _icon_dir is "resources/aws/compute" — strip leading segment
+ "icon": "/".join([*Path(cls._icon_dir).parts[1:], cls._icon]),
+ }
+ for name, cls in sorted(classes.items())
+ ]
+
+ def signature_params(fn) -> list:
+ return [str(p) for p in list(inspect.signature(fn).parameters.values())[1:]]
+
+ return {
+ "modules": modules,
+ "signatures": {
+ "Diagram": signature_params(Diagram.__init__),
+ "Cluster": signature_params(Cluster.__init__),
+ "Edge": signature_params(Edge.__init__),
+ },
+ }
+
+
+def build_slim_wheel(repo_root: Path, out_dir: Path) -> Path:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as tmp:
+ subprocess.run(
+ [sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", tmp, str(repo_root)],
+ check=True,
+ )
+ src = next(Path(tmp).glob("diagrams-*.whl"))
+ dst = out_dir / src.name
+ with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+ for item in zin.infolist():
+ if item.filename.startswith("resources/"):
+ continue
+ data = zin.read(item.filename)
+ if item.filename.endswith(".dist-info/RECORD"):
+ lines = [l for l in data.decode().splitlines() if not l.startswith("resources/")]
+ data = ("\n".join(lines) + "\n").encode()
+ zout.writestr(item, data)
+ return dst
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-root", type=Path, required=True)
+ parser.add_argument("--out", type=Path, required=True)
+ args = parser.parse_args()
+ repo_root, out = args.repo_root.resolve(), args.out.resolve()
+
+ catalog = build_catalog(repo_root)
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "catalog.json").write_text(json.dumps(catalog))
+ print(f"catalog.json: {sum(len(v) for v in catalog['modules'].values())} classes")
+
+ icons_dir = out / "icons"
+ shutil.copytree(repo_root / "resources", icons_dir, dirs_exist_ok=True)
+ print(f"icons: copied to {icons_dir}")
+
+ wheel = build_slim_wheel(repo_root, out / "wheels")
+ (out / "wheels" / "manifest.json").write_text(json.dumps({"wheel": wheel.name}))
+ print(f"wheel: {wheel.name} ({wheel.stat().st_size // 1024} KiB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/playground/scripts/test_gen_catalog.py b/playground/scripts/test_gen_catalog.py
new file mode 100644
index 00000000..eda44f3f
--- /dev/null
+++ b/playground/scripts/test_gen_catalog.py
@@ -0,0 +1,60 @@
+import importlib.util
+import zipfile
+from pathlib import Path
+
+# Load the sibling gen_catalog.py by file path rather than a bare
+# `from gen_catalog import ...` after a sys.path hack — the latter forces an
+# import that isort keeps reordering above the path setup (breaking it) and
+# that seed-isort-config misclassifies as third-party.
+_spec = importlib.util.spec_from_file_location("gen_catalog", Path(__file__).parent / "gen_catalog.py")
+gen_catalog = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(gen_catalog)
+build_catalog = gen_catalog.build_catalog
+build_slim_wheel = gen_catalog.build_slim_wheel
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_catalog_contains_ec2_with_icon():
+ catalog = build_catalog(REPO_ROOT)
+ compute = catalog["modules"]["diagrams.aws.compute"]
+ ec2 = next(c for c in compute if c["name"] == "EC2")
+ assert ec2["icon"] == "aws/compute/ec2.png"
+ assert (REPO_ROOT / "resources" / ec2["icon"]).exists()
+
+
+def test_catalog_records_aliases():
+ catalog = build_catalog(REPO_ROOT)
+ compute = catalog["modules"]["diagrams.aws.compute"]
+ ecs = next(c for c in compute if c["name"] == "ElasticContainerService")
+ assert "ECS" in ecs["aliases"]
+
+
+def test_catalog_signatures_have_core_classes():
+ catalog = build_catalog(REPO_ROOT)
+ assert any(p.startswith("name") for p in catalog["signatures"]["Diagram"])
+ assert any(p.startswith("label") for p in catalog["signatures"]["Cluster"])
+ assert any(p.startswith("forward") for p in catalog["signatures"]["Edge"])
+
+
+def test_all_catalog_icons_exist_on_disk():
+ catalog = build_catalog(REPO_ROOT)
+ missing = [
+ c["icon"]
+ for classes in catalog["modules"].values()
+ for c in classes
+ if not (REPO_ROOT / "resources" / c["icon"]).exists()
+ ]
+ assert missing == []
+
+
+def test_slim_wheel_has_no_resources(tmp_path):
+ wheel = build_slim_wheel(REPO_ROOT, tmp_path)
+ with zipfile.ZipFile(wheel) as zf:
+ names = zf.namelist()
+ assert not [n for n in names if n.startswith("resources/")]
+ assert [n for n in names if n.startswith("diagrams/")]
+ record = next(n for n in names if n.endswith(".dist-info/RECORD"))
+ record_body = zf.read(record).decode()
+ assert "resources/" not in record_body
+ assert wheel.stat().st_size < 3_000_000 # confirms the 38MB resources are stripped
diff --git a/playground/scripts/test_shim.py b/playground/scripts/test_shim.py
new file mode 100644
index 00000000..2176cff0
--- /dev/null
+++ b/playground/scripts/test_shim.py
@@ -0,0 +1,128 @@
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SHIM = REPO_ROOT / "playground" / "src" / "worker" / "shim.py"
+sys.path.insert(0, str(REPO_ROOT))
+
+
+# Fixture to restore json module after each test
+@pytest.fixture(autouse=True)
+def restore_json_module():
+ import json.encoder as je
+
+ original_dumps = json.dumps
+ original_encode_basestring_ascii = je.encode_basestring_ascii
+ original_encode_basestring = je.encode_basestring
+ original_c_encode_basestring_ascii = je.c_encode_basestring_ascii
+ original_c_make_encoder = je.c_make_encoder
+ yield
+ json.dumps = original_dumps
+ je.encode_basestring_ascii = original_encode_basestring_ascii
+ je.encode_basestring = original_encode_basestring
+ je.c_encode_basestring_ascii = original_c_encode_basestring_ascii
+ je.c_make_encoder = original_c_make_encoder
+
+
+namespace = {}
+exec(compile(SHIM.read_text(), str(SHIM), "exec"), namespace)
+run_user_code = namespace["run_user_code"]
+
+SAMPLE = """
+from diagrams import Diagram
+from diagrams.aws.compute import EC2
+from diagrams.aws.network import ELB
+
+with Diagram("Web Service", show=False):
+ ELB("lb") >> EC2("web")
+"""
+
+
+def test_captures_dot_source():
+ result = json.loads(run_user_code(SAMPLE))
+ assert result["error"] is None
+ assert len(result["dots"]) == 1
+ assert result["dots"][0]["name"] == "Web Service"
+ assert "elastic-load-balancing.png" in result["dots"][0]["source"]
+ assert "digraph" in result["dots"][0]["source"]
+
+
+def test_no_output_files_written(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ json.loads(run_user_code(SAMPLE))
+ assert list(tmp_path.iterdir()) == []
+
+
+def test_captures_multiple_diagrams():
+ code = SAMPLE + '\nwith Diagram("Second", show=False):\n EC2("solo")\n'
+ result = json.loads(run_user_code(code))
+ assert [d["name"] for d in result["dots"]] == ["Web Service", "Second"]
+
+
+def test_explicit_render_call_not_duplicated():
+ code = """
+from diagrams import Diagram
+from diagrams.aws.compute import EC2
+with Diagram("D", show=False) as d:
+ EC2("a")
+ d.render()
+"""
+ result = json.loads(run_user_code(code))
+ assert len(result["dots"]) == 1
+
+
+def test_error_returns_clean_traceback():
+ result = json.loads(run_user_code("from diagrams import Diagram\n1/0\n"))
+ assert result["dots"] == []
+ assert "ZeroDivisionError" in result["error"]
+ assert "line 2" in result["error"]
+ assert "shim.py" not in result["error"]
+
+
+def test_stdout_captured():
+ result = json.loads(run_user_code('print("hello")'))
+ assert result["stdout"] == "hello\n"
+
+
+def test_exception_inside_diagram_block_not_captured():
+ code = """
+from diagrams import Diagram
+from diagrams.aws.compute import EC2
+try:
+ with Diagram("Broken", show=False):
+ EC2("a")
+ raise RuntimeError("boom")
+except RuntimeError:
+ pass
+with Diagram("After", show=False):
+ EC2("b")
+"""
+ result = json.loads(run_user_code(code))
+ assert [d["name"] for d in result["dots"]] == ["After"]
+ assert result["error"] is None
+
+
+def test_json_sabotage_still_returns_json():
+ code = "import json\njson.dumps = None\nprint('ok')"
+ result = json.loads(run_user_code(code))
+ assert result["error"] is None
+ assert result["stdout"] == "ok\n"
+
+
+def test_json_encoder_sabotage_still_returns_json():
+ code = """
+import json.encoder as je
+def evil(*a, **k):
+ raise RuntimeError("pwned")
+je.encode_basestring_ascii = evil
+je.encode_basestring = evil
+je.c_encode_basestring_ascii = None
+je.c_make_encoder = None
+"""
+ result = json.loads(run_user_code(code))
+ assert result["dots"] == []
+ assert "Internal error serializing result" in result["error"]
+ assert "pwned" in result["error"]
diff --git a/playground/src/App.tsx b/playground/src/App.tsx
new file mode 100644
index 00000000..f66246c0
--- /dev/null
+++ b/playground/src/App.tsx
@@ -0,0 +1,241 @@
+import { autocompletion } from "@codemirror/autocomplete";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import DragHandle from "./components/DragHandle";
+import EditorPane from "./components/EditorPane";
+import ErrorPanel from "./components/ErrorPanel";
+import ExamplesGallery from "./components/ExamplesGallery";
+import ExportBar from "./components/ExportBar";
+import NodeSearch from "./components/NodeSearch";
+import PreviewPane from "./components/PreviewPane";
+import Toolbar from "./components/Toolbar";
+import { diagramsCompletions } from "./completions/imports";
+import { signatureTooltip } from "./completions/signature";
+import { DEFAULT_CODE, EXAMPLES } from "./examples";
+import { renderDot } from "./renderer/render";
+import { decodeShare, encodeShare } from "./share/codec";
+import type { Catalog } from "./types";
+import { debounce } from "./utils/debounce";
+import { clampRatio, clampSidebarWidth, DEFAULT_RATIO, DEFAULT_SIDEBAR_WIDTH } from "./utils/layout";
+import { renameDiagramInCode } from "./utils/rename";
+import { initTheme } from "./utils/theme";
+import { PyClient, SupersededError, TimeoutError } from "./worker/client";
+
+const SPLIT_STORAGE_KEY = "dgp-split";
+const SIDEBAR_STORAGE_KEY = "dgp-sidebar";
+
+function loadStoredRatio(): number {
+ const stored = Number(localStorage.getItem(SPLIT_STORAGE_KEY));
+ return clampRatio(Number.isFinite(stored) && stored !== 0 ? stored : DEFAULT_RATIO);
+}
+
+function loadStoredSidebarWidth(): number {
+ const stored = Number(localStorage.getItem(SIDEBAR_STORAGE_KEY));
+ return clampSidebarWidth(Number.isFinite(stored) && stored !== 0 ? stored : DEFAULT_SIDEBAR_WIDTH);
+}
+
+const STATUS_BY_STAGE: Record = {
+ pyodide: "Loading Python runtime… (first visit only)",
+ packages: "Installing diagrams package…",
+ ready: "Ready",
+};
+
+export default function App() {
+ const clientRef = useRef();
+ const replaceCodeRef = useRef<(code: string) => void>();
+ const codeRef = useRef(decodeShare(window.location.hash) ?? DEFAULT_CODE);
+
+ const [catalog, setCatalog] = useState(null);
+ const [catalogError, setCatalogError] = useState(null);
+ const [status, setStatus] = useState("Starting…");
+ const [ready, setReady] = useState(false);
+ const [svgs, setSvgs] = useState<{ name: string; svg: string }[]>([]);
+ const [error, setError] = useState(null);
+ const [rendering, setRendering] = useState(false);
+ const [shared, setShared] = useState(false);
+ const [splitRatio, setSplitRatio] = useState(loadStoredRatio);
+ const [sidebarWidth, setSidebarWidth] = useState(loadStoredSidebarWidth);
+ const [lineCount, setLineCount] = useState(() => codeRef.current.split("\n").length);
+ // A share-link boot loads its code straight into the editor, so no example
+ // pill should read as "active" until the user explicitly picks one.
+ const [activeExample, setActiveExample] = useState(() =>
+ decodeShare(window.location.hash) !== null ? null : EXAMPLES[0].title
+ );
+ const [renderMs, setRenderMs] = useState(null);
+ const splitContainerRef = useRef(null);
+ const mainRef = useRef(null);
+
+ useEffect(() => {
+ initTheme();
+ }, []);
+
+ const handleSplitChange = useCallback((ratio: number) => {
+ const clamped = clampRatio(ratio);
+ setSplitRatio(clamped);
+ localStorage.setItem(SPLIT_STORAGE_KEY, String(clamped));
+ }, []);
+
+ const handleSidebarChange = useCallback((width: number) => {
+ const clamped = clampSidebarWidth(width);
+ setSidebarWidth(clamped);
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, String(clamped));
+ }, []);
+
+ const execute = useCallback(async (code: string) => {
+ codeRef.current = code;
+ const client = clientRef.current;
+ if (!client) return;
+ setRendering(true);
+ const startedAt = performance.now();
+ try {
+ const result = await client.run(code);
+ if (result.error) {
+ setError(result.error); // keep the last successful preview on screen
+ } else {
+ const rendered = await Promise.all(
+ result.dots.map(async (d) => ({ name: d.name, svg: await renderDot(d.source) }))
+ );
+ setSvgs(rendered);
+ setError(null);
+ setRenderMs(Math.round(performance.now() - startedAt));
+ }
+ } catch (err) {
+ if (err instanceof SupersededError) return;
+ if (err instanceof TimeoutError) {
+ setError("Execution timed out after 10s. The Python runtime was restarted (infinite loop?).");
+ } else {
+ setError(String(err));
+ }
+ } finally {
+ setRendering(false);
+ }
+ }, []);
+
+ const debouncedExecute = useMemo(() => debounce(execute, 500), [execute]);
+
+ // codeRef must track EVERY keystroke immediately — handleShare/insertImport
+ // read it synchronously; only the execution is debounced.
+ const handleEditorChange = useCallback(
+ (code: string) => {
+ codeRef.current = code;
+ setLineCount(code.split("\n").length);
+ debouncedExecute(code);
+ },
+ [debouncedExecute]
+ );
+
+ useEffect(() => {
+ fetch("catalog.json")
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ })
+ .then(setCatalog)
+ .catch((err) => {
+ setCatalog(null);
+ setCatalogError(
+ `Node catalog failed to load (${err instanceof Error ? err.message : String(err)}) — autocomplete and node search are disabled.`
+ );
+ });
+
+ const client = new PyClient();
+ clientRef.current = client;
+ client
+ .init((stage) => {
+ if (clientRef.current === client) setStatus(STATUS_BY_STAGE[stage] ?? stage);
+ })
+ .then(() => {
+ if (clientRef.current !== client) return;
+ setReady(true);
+ void execute(codeRef.current);
+ })
+ .catch((err) => {
+ if (String(err).includes("disposed")) return; // our own cleanup
+ if (clientRef.current === client) setStatus(`Failed to start: ${err}`);
+ });
+ return () => client.dispose();
+ }, [execute]);
+
+ const editorExtensions = useMemo(() => {
+ if (!catalog) return [];
+ return [
+ autocompletion({ override: [diagramsCompletions(catalog)] }),
+ signatureTooltip(catalog.signatures),
+ ];
+ }, [catalog]);
+
+ function handleShare() {
+ const hash = `#code=${encodeShare(codeRef.current)}`;
+ window.history.replaceState(null, "", hash);
+ navigator.clipboard
+ .writeText(window.location.href)
+ .then(() => {
+ setShared(true);
+ setTimeout(() => setShared(false), 2000);
+ })
+ .catch(() => setShared(false));
+ }
+
+ function loadCode(code: string) {
+ replaceCodeRef.current?.(code);
+ void execute(code);
+ }
+
+ function insertImport(importStmt: string) {
+ loadCode(`${importStmt}\n${codeRef.current}`);
+ }
+
+ function handleSelectExample(example: { title: string; code: string }) {
+ setActiveExample(example.title);
+ loadCode(example.code);
+ }
+
+ function handleRenameDiagram(index: number, name: string) {
+ const next = renameDiagramInCode(codeRef.current, index, name);
+ if (next) loadCode(next);
+ }
+
+ return (
+
+
+
+
+
+ clampSidebarWidth(clientX - rect.left)}
+ resetValue={DEFAULT_SIDEBAR_WIDTH}
+ />
+
+
+ (replaceCodeRef.current = fn)}
+ onRunNow={() => void execute(codeRef.current)}
+ onShare={handleShare}
+ />
+
+
+
+
+ rect.width === 0 ? DEFAULT_RATIO : clampRatio(((clientX - rect.left) / rect.width) * 100)
+ }
+ resetValue={DEFAULT_RATIO}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/playground/src/app.css b/playground/src/app.css
new file mode 100644
index 00000000..59bff561
--- /dev/null
+++ b/playground/src/app.css
@@ -0,0 +1,1308 @@
+/* ============================================================
+ Diagrams Playground — light-first soft modern SaaS theme
+ ============================================================ */
+
+/* ---------- Theme-agnostic tokens ---------- */
+:root {
+ --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
+ --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+
+ --radius-sm: 10px; /* buttons / inputs */
+ --radius-md: 12px; /* cards / panels */
+ --radius-lg: 16px; /* diagram card */
+
+ --accent: #6366f1;
+ --accent-hover: #4f46e5;
+ --accent-soft: #eef2ff;
+}
+
+/* ---------- Light theme (DEFAULT) ---------- */
+:root[data-theme="light"] {
+ --page-bg: #ffffff;
+ --panel-bg: #ffffff;
+ --panel-alt: #f8f9fb;
+ --input-bg: #f3f4f6;
+ --border: #e5e7eb;
+ --text: #111827;
+ --text-muted: #6b7280;
+
+ /* Accordion surfaces: provider rows sit on --panel-bg at rest; --surface-1
+ tints a provider row's header on hover and while expanded
+ (aria-expanded), and --surface-2 tints that header's count badge in the
+ same states, kept distinct from --surface-1 so the badge still reads as
+ a separate pill. */
+ --surface-1: #f1f2f5;
+ --surface-2: #fafbfc;
+
+ --shadow-1: 0 1px 2px rgba(17, 24, 39, 0.04), 0 1px 3px rgba(17, 24, 39, 0.06);
+ --shadow-2: 0 4px 24px rgba(17, 24, 39, 0.08);
+
+ --status-ready-bg: #dcfce7;
+ --status-ready-fg: #15803d;
+ --status-busy-bg: #fef3c7;
+ --status-busy-fg: #b45309;
+ --status-error-bg: #fee2e2;
+ --status-error-fg: #b91c1c;
+
+ --share-bg: #111827;
+ --share-fg: #ffffff;
+
+ --canvas-bg: #fafafb;
+ --canvas-dot: #d1d5db;
+
+ --error-bg: #fef2f2;
+ --error-border: #fecaca;
+ --error-fg: #b91c1c;
+
+ --chip-bg: rgba(255, 255, 255, 0.92);
+ --chip-fg: var(--text-muted);
+
+ --cm-bg: #ffffff;
+ --cm-fg: var(--text);
+ --cm-caret: #111827;
+ --cm-selection: rgba(99, 102, 241, 0.18);
+ --cm-active-line: #f8f9fb;
+ --cm-gutter-bg: #ffffff;
+ --cm-gutter-fg: var(--text-muted);
+ --cm-gutter-border: var(--border);
+ --cm-active-gutter-bg: #f3f4f6;
+ --cm-active-gutter-fg: var(--text);
+ --cm-selection-match: rgba(99, 102, 241, 0.15);
+ --cm-matching-bracket-bg: rgba(99, 102, 241, 0.15);
+ --cm-matching-bracket-outline: var(--accent);
+ --cm-tooltip-bg: #ffffff;
+ --cm-tooltip-border: var(--border);
+ --cm-tooltip-fg: var(--text);
+ --cm-autocomplete-selected-bg: var(--accent);
+ --cm-autocomplete-selected-fg: #ffffff;
+
+ --syn-keyword: #7c3aed;
+ --syn-string: #c2410c;
+ --syn-comment: #9ca3af;
+ --syn-function: #2563eb;
+ --syn-number: #0d9488;
+ --syn-operator: var(--text);
+ --syn-variable: var(--text);
+ --syn-property: #2563eb;
+ --syn-bool: #7c3aed;
+ --syn-atom: #7c3aed;
+}
+
+/* ---------- Dark theme (neutral grays, indigo accent stays) ---------- */
+:root[data-theme="dark"] {
+ --page-bg: #0f1115;
+ --panel-bg: #16181d;
+ --panel-alt: #1b1e24;
+ --input-bg: #1e2127;
+ --border: #2a2d34;
+ --text: #e5e7eb;
+ --text-muted: #9ca3af;
+
+ /* Accordion surfaces — dark equivalents, see light theme comment above. */
+ --surface-1: #1e2128;
+ --surface-2: #1a1c22;
+
+ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.24);
+ --shadow-2: 0 4px 24px rgba(0, 0, 0, 0.4);
+
+ --status-ready-bg: rgba(34, 197, 94, 0.16);
+ --status-ready-fg: #4ade80;
+ --status-busy-bg: rgba(245, 158, 11, 0.16);
+ --status-busy-fg: #fbbf24;
+ --status-error-bg: rgba(239, 68, 68, 0.16);
+ --status-error-fg: #f87171;
+
+ --share-bg: #ffffff;
+ --share-fg: #111827;
+
+ --canvas-bg: #131519;
+ --canvas-dot: #2a2d34;
+
+ --error-bg: rgba(239, 68, 68, 0.1);
+ --error-border: rgba(239, 68, 68, 0.35);
+ --error-fg: #f87171;
+
+ --chip-bg: rgba(22, 24, 29, 0.85);
+ --chip-fg: var(--text-muted);
+
+ --cm-bg: #16181d;
+ --cm-fg: var(--text);
+ --cm-caret: #e5e7eb;
+ --cm-selection: rgba(99, 102, 241, 0.32);
+ --cm-active-line: #1b1e24;
+ --cm-gutter-bg: #16181d;
+ --cm-gutter-fg: var(--text-muted);
+ --cm-gutter-border: var(--border);
+ --cm-active-gutter-bg: #1e2127;
+ --cm-active-gutter-fg: var(--text);
+ --cm-selection-match: rgba(99, 102, 241, 0.25);
+ --cm-matching-bracket-bg: rgba(99, 102, 241, 0.25);
+ --cm-matching-bracket-outline: var(--accent);
+ --cm-tooltip-bg: #1b1e24;
+ --cm-tooltip-border: var(--border);
+ --cm-tooltip-fg: var(--text);
+ --cm-autocomplete-selected-bg: var(--accent);
+ --cm-autocomplete-selected-fg: #ffffff;
+
+ --syn-keyword: #a78bfa;
+ --syn-string: #fb923c;
+ --syn-comment: #6b7280;
+ --syn-function: #60a5fa;
+ --syn-number: #2dd4bf;
+ --syn-operator: var(--text);
+ --syn-variable: var(--text);
+ --syn-property: #60a5fa;
+ --syn-bool: #a78bfa;
+ --syn-atom: #a78bfa;
+}
+
+/* ---------- Reset & base ---------- */
+* {
+ box-sizing: border-box;
+ margin: 0;
+}
+html,
+body,
+#root,
+.app {
+ height: 100%;
+ font-family: var(--font-sans);
+ background: var(--page-bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+}
+.app {
+ display: flex;
+ flex-direction: column;
+ transition: background-color 160ms ease, color 160ms ease;
+}
+
+/* ---------- Scrollbars ---------- */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+*::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+*::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 6px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+*::-webkit-scrollbar-thumb:hover {
+ background: var(--accent);
+ background-clip: padding-box;
+}
+
+/* ---------- Motion ---------- */
+@keyframes fade-slide {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes loading-sweep {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(100%);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
+
+/* ---------- Buttons (shared baseline) ---------- */
+.app button {
+ font-family: var(--font-sans);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease,
+ box-shadow 120ms ease;
+}
+.app button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Ghost bordered button — used for Theme / GitHub / Docs. Fixed height so
+ the GitHub button (whose inner star pill would otherwise inflate it)
+ lines up exactly with its plain siblings. */
+.btn-ghost {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ box-sizing: border-box;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ padding: 0 12px;
+ text-decoration: none;
+ font-size: 13px;
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ transition: border-color 120ms ease, color 120ms ease, background-color 120ms ease;
+}
+.btn-ghost:hover,
+.btn-ghost:focus-visible {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+.btn-ghost:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Star-count badge inside the GitHub ghost button (logo + "GitHub" + count). */
+.gh-stars {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: 2px;
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1;
+}
+.gh-stars svg {
+ color: #f5b400;
+}
+
+/* Filled dark "Share" button — same fixed height as the ghost buttons. */
+.btn-share {
+ display: inline-flex;
+ align-items: center;
+ height: 32px;
+ box-sizing: border-box;
+ color: var(--share-fg);
+ background: var(--share-bg);
+ border: 1px solid var(--share-bg);
+ padding: 0 16px;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+}
+.btn-share:hover {
+ opacity: 0.9;
+}
+
+/* ---------- Header (single row) ---------- */
+.toolbar {
+ position: relative;
+ background: var(--page-bg);
+ border-bottom: 1px solid var(--border);
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ animation: fade-slide 420ms ease-out both;
+}
+.toolbar::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -1px;
+ height: 2px;
+ background: var(--accent);
+ opacity: 0;
+ transform: translateX(-100%);
+ pointer-events: none;
+}
+.toolbar.is-loading::after {
+ opacity: 1;
+ animation: loading-sweep 1.1s ease-in-out infinite;
+}
+/* White chip in BOTH themes — the logo's dark strokes need a light ground. */
+.app-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+ border-radius: 8px;
+ background: #ffffff;
+}
+.toolbar-title {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 16px;
+ color: var(--text);
+ letter-spacing: -0.01em;
+}
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px 4px 8px;
+ border-radius: 999px;
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+}
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.status-pill--ready {
+ background: var(--status-ready-bg);
+ color: var(--status-ready-fg);
+}
+.status-pill--ready .status-dot {
+ background: var(--status-ready-fg);
+}
+.status-pill--busy {
+ background: var(--status-busy-bg);
+ color: var(--status-busy-fg);
+}
+.status-pill--busy .status-dot {
+ background: var(--status-busy-fg);
+}
+.status-pill--error {
+ background: var(--status-error-bg);
+ color: var(--status-error-fg);
+}
+.status-pill--error .status-dot {
+ background: var(--status-error-fg);
+}
+.toolbar-spacer {
+ flex: 1;
+}
+.toolbar-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ---------- Examples row ---------- */
+.examples {
+ position: relative;
+ background: var(--page-bg);
+ border-bottom: 1px solid var(--border);
+ padding: 10px 20px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ animation: fade-slide 420ms ease-out both;
+ animation-delay: 30ms;
+}
+.examples-label {
+ font-family: var(--font-sans);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-right: 4px;
+}
+.example-pill {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 12px;
+}
+.example-pill:hover,
+.example-pill:focus-visible {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+.example-pill.is-active {
+ background: var(--accent-soft);
+ border-color: var(--accent);
+ color: var(--accent-hover);
+}
+
+/* ---------- Main row ---------- */
+/* Flat layout (mockup): panels butt together and are separated by 1px
+ hairlines only — no gaps, no per-panel cards/shadows/rounding. */
+.main {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+ gap: 0;
+ padding: 0;
+ background: var(--panel-bg);
+}
+
+/* ---------- Split container (editor + handle + preview) ---------- */
+.split-container {
+ flex: 1;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+}
+
+/* ---------- Node search sidebar ---------- */
+/* Width is set inline (resizable via DragHandle); 260px is the CSS
+ fallback. The right divider line is the DragHandle itself. */
+.node-search {
+ width: 260px;
+ flex-shrink: 0;
+ background: var(--panel-bg);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ animation: fade-slide 420ms ease-out both;
+}
+.search-input-wrap {
+ position: relative;
+ margin: 12px;
+}
+.search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-muted);
+ pointer-events: none;
+ display: inline-flex;
+}
+.node-search input {
+ width: 100%;
+ padding: 8px 10px 8px 32px;
+ background: var(--input-bg);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 13px;
+ outline: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
+}
+.node-search input::placeholder {
+ color: var(--text-muted);
+}
+.node-search input:focus {
+ background: var(--panel-bg);
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.node-search-results {
+ flex: 1;
+ overflow-y: auto;
+ list-style: none;
+ padding: 0 8px 12px;
+ font-size: 13px;
+}
+
+/* Shared class row (flat search results + expanded-category tree rows).
+ Fixed layout that never shifts — no hover-revealed controls live here;
+ actions are a right-click context menu instead (see .ctx-menu below).
+ Row hover/focus is a subtle background tint only.
+ Horizontal padding lives on the row itself (not on an ancestor ), so
+ the row's own box — and therefore its hover/focus background — always
+ spans the full width of the results list, edge to edge, regardless of
+ tree depth; only the padding-left (content inset) grows with depth. */
+.hit-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background-color 100ms ease;
+}
+.hit-row:hover,
+.hit-row:focus-visible {
+ background-color: var(--panel-alt);
+}
+.hit-row:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+/* Class row nested inside an expanded tree category (depth 2): the
+ .tree-class-rows container carries the indent + rail (see below); the
+ row itself only keeps its base padding. */
+.hit-row.is-tree-class {
+ padding-left: 8px;
+}
+/* Bare node icon — the real icon image with no wrapper box (no border,
+ background, or radius): just a fixed-size flex slot for alignment. */
+.hit-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+.hit-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text);
+}
+.hit-module {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ flex-shrink: 0;
+ max-width: 40%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Right-click context menu for a hit row: Copy import / Insert import.
+ Portaled to document.body (see NodeSearch.tsx) so position:fixed is
+ always viewport-relative; z-index 1000 keeps it above the editor. */
+.ctx-menu {
+ position: fixed;
+ z-index: 1000;
+ min-width: 160px;
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: var(--shadow-2);
+ animation: fade-slide 100ms ease-out both;
+}
+.ctx-menu button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 6px 10px;
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-sm);
+}
+.ctx-menu button:hover,
+.ctx-menu button:focus-visible {
+ background: var(--accent-soft);
+ color: var(--accent-hover);
+}
+
+/* ---------- Node tree (browse view, shown when search is empty) ----------
+ Mockup style: no section divider lines, rounded row highlights, count
+ PILLS on provider rows, mono category names, and one thin vertical rail
+ per expanded level for depth. */
+.tree-node {
+ display: block;
+ padding: 0;
+}
+.tree-row {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 8px;
+ background-color: transparent;
+ border: none;
+ border-radius: 8px;
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 13px;
+ text-align: left;
+ transition: background-color 100ms ease;
+}
+.tree-row:hover {
+ background-color: var(--panel-alt);
+}
+/* Provider row: rounded highlight while open (mockup), sticky while its
+ subtree scrolls so you always know which provider you're browsing.
+ Opaque bg when stuck so rows don't ghost through underneath. */
+.tree-provider-row {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background-color: var(--panel-bg);
+}
+.tree-provider-row:hover,
+.tree-provider-row[aria-expanded="true"] {
+ background-color: var(--surface-1);
+}
+/* Centered SVG caret (see Chevron in NodeSearch.tsx): fixed square box +
+ center transform-origin so the open-rotation spins in place at BOTH tree
+ levels. */
+.tree-chevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ color: var(--text-muted);
+ transition: transform 150ms ease;
+ transform-origin: center;
+}
+.tree-provider-row[aria-expanded="true"] .tree-chevron,
+.tree-category-row[aria-expanded="true"] .tree-chevron {
+ transform: rotate(90deg);
+}
+.tree-provider-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+}
+.tree-category-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+ font-weight: 500;
+ font-size: 12.5px;
+ color: var(--text);
+}
+/* Counts: provider level = rounded pill, category level = plain muted. */
+.tree-badge {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+.tree-provider-row .tree-badge {
+ background: var(--surface-1);
+ border-radius: 999px;
+ padding: 2px 8px;
+}
+.tree-provider-row:hover .tree-badge,
+.tree-provider-row[aria-expanded="true"] .tree-badge {
+ background: var(--surface-2);
+}
+/* Expanded levels: no bg tint — a single thin vertical rail per level
+ (mockup) carries the hierarchy, aligned under the parent's chevron. */
+.tree-children {
+ list-style: none;
+ margin: 2px 0 6px 17px;
+ padding-left: 4px;
+ border-left: 1px solid var(--border);
+}
+.tree-class-rows {
+ list-style: none;
+ margin: 2px 0 6px 17px;
+ padding-left: 4px;
+ border-left: 1px solid var(--border);
+}
+
+/* ---------- Editor column ---------- */
+.editor-column {
+ /* flex-basis is set inline as a ${splitRatio}% of .split-container; no
+ grow/shrink so the drag ratio is respected exactly, with the preview
+ pane (flex: 1) filling whatever remains. */
+ flex: 0 0 auto;
+ min-width: 160px;
+ display: flex;
+ flex-direction: column;
+ background: var(--panel-bg);
+ overflow: hidden;
+ animation: fade-slide 420ms ease-out both;
+ animation-delay: 60ms;
+}
+.editor-pane-wrap {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+/* Fixed height shared with .preview-header so the two panel headers sit on
+ the same baseline and their bottom hairlines form one continuous line. */
+.editor-topbar {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 46px;
+ box-sizing: border-box;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--panel-bg);
+}
+.editor-filename {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--text);
+}
+.editor-linecount {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.editor-pane {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+.editor-pane .cm-editor {
+ height: 100%;
+}
+/* Line-number gutter: slightly smaller digits than the code, with a bit of
+ breathing room on both sides of the number column. */
+.editor-pane .cm-lineNumbers .cm-gutterElement {
+ font-size: 11.5px;
+ padding: 0 10px 0 12px;
+}
+.cm-signature-hint {
+ background: var(--panel-bg);
+ color: var(--accent-hover);
+ padding: 5px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ max-width: 480px;
+ box-shadow: var(--shadow-2);
+}
+
+/* ---------- Split handle ---------- */
+/* A flush 1px divider that takes exactly 1px of layout — no white gutter
+ on either side. The invisible ::before overlay extends the drag hit-area
+ 4px into each neighboring panel (pseudo-element events hit the handle). */
+.split-handle {
+ flex: 0 0 1px;
+ width: 1px;
+ cursor: col-resize;
+ background: var(--border);
+ position: relative;
+ touch-action: none;
+ transition: background-color 120ms ease;
+ z-index: 4;
+}
+.split-handle::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -4px;
+ right: -4px;
+ background: transparent;
+}
+.split-handle:hover,
+.split-handle.is-active {
+ background: var(--accent);
+}
+
+/* ---------- Error panel (traceback) ---------- */
+.error-panel {
+ max-height: 30%;
+ overflow: auto;
+ background: var(--error-bg);
+ color: var(--error-fg);
+ padding: 12px 14px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ border-top: 1px solid var(--error-border);
+ /* The export bar is now always the last element in .editor-column, so it
+ owns the rounded bottom corners instead of this panel. */
+}
+.error-panel::before {
+ content: "TRACEBACK";
+ display: block;
+ font-family: var(--font-sans);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ font-size: 11px;
+ text-transform: uppercase;
+ color: var(--error-fg);
+ opacity: 0.85;
+ margin-bottom: 5px;
+}
+
+/* ---------- Preview pane ---------- */
+.preview-pane {
+ flex: 1 1 0;
+ min-width: 160px;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ background: var(--panel-bg);
+ overflow: hidden;
+ animation: fade-slide 420ms ease-out both;
+ animation-delay: 120ms;
+}
+
+/* Fixed header bar: a real flex sibling above `.preview-canvas` (not part of
+ the transformed content), so it never pans/zooms with the diagram. Opaque
+ themed surface + hairline bottom border + z-index keep it visually
+ anchored above the canvas at all times. */
+.preview-header {
+ position: relative;
+ z-index: 3;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ height: 46px;
+ box-sizing: border-box;
+ padding: 0 16px;
+ background: var(--panel-bg);
+ border-bottom: 1px solid var(--border);
+}
+
+/* True infinite canvas: a fixed, clipped viewport. All pan/zoom lives in the
+ `.preview-content` transform below — this element never scrolls. The
+ dot-grid background-position is driven by tx/ty (inline style) so the
+ grid visually pans in lockstep with the content. */
+.preview-canvas {
+ position: relative;
+ flex: 1;
+ overflow: hidden;
+ touch-action: none;
+ color: var(--text);
+ background-color: var(--canvas-bg);
+ background-image: radial-gradient(var(--canvas-dot) 1px, transparent 1px);
+ background-size: 16px 16px;
+ transition: background-color 160ms ease;
+ cursor: grab;
+}
+.preview-canvas.is-panning {
+ cursor: grabbing;
+ user-select: none;
+}
+
+/* Absolutely-positioned content layer — translate(tx,ty) scale(s) with
+ transform-origin 0 0 is applied inline (see PreviewPane.tsx) so tx/ty can
+ go negative with no bounds, unlike the old scrollLeft/scrollTop pan. */
+.preview-content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: 0 0;
+}
+
+.diagram-block {
+ margin-bottom: 28px;
+}
+/* Per-sheet name label, shown above each sheet only when there are multiple
+ diagrams (the fixed header only ever shows the first diagram's name), so
+ panning users can tell sheets apart. */
+.sheet-label {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.diagram-header-left {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ min-width: 0;
+}
+.diagram-name {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+/* Click-to-edit affordance: the pencil icon stays hidden until hover/focus so
+ the header doesn't look cluttered while just viewing the diagram. */
+.diagram-name--editable {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+}
+.diagram-name--editable:hover,
+.diagram-name--editable:focus-visible {
+ color: var(--accent-hover);
+}
+.diagram-name-edit-icon {
+ font-size: 11px;
+ color: var(--text-muted);
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+.diagram-name--editable:hover .diagram-name-edit-icon,
+.diagram-name--editable:focus-visible .diagram-name-edit-icon {
+ opacity: 1;
+}
+/* Inline rename input — same font/size as `.diagram-name` so the swap
+ doesn't reflow the header, plus a subtle bordered field to read as
+ editable. */
+.diagram-name-input {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 1px 6px;
+ min-width: 80px;
+ max-width: 260px;
+ outline: none;
+}
+.diagram-name-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.diagram-meta {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.diagram-header-right {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.zoom-segment {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+.zoom-segment button {
+ border: none;
+ border-radius: 0;
+ background: var(--panel-bg);
+ color: var(--text);
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1;
+}
+.zoom-segment button + button {
+ border-left: 1px solid var(--border);
+}
+.zoom-segment button:hover {
+ color: var(--accent-hover);
+}
+.zoom-pct {
+ font-family: var(--font-mono);
+ min-width: 44px;
+ text-align: center;
+}
+
+.preview-overlay-chip {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 10px;
+ border-radius: 999px;
+ background: var(--chip-bg);
+ border: 1px solid var(--border);
+ color: var(--chip-fg);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+ box-shadow: var(--shadow-1);
+ pointer-events: none;
+ backdrop-filter: blur(4px);
+}
+
+.render-ms-chip {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ z-index: 2;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: var(--chip-bg);
+ border: 1px solid var(--border);
+ color: var(--chip-fg);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ box-shadow: var(--shadow-1);
+ pointer-events: none;
+ backdrop-filter: blur(4px);
+}
+
+/* ---------- Export bar (permanently visible, docked under the editor) ----------
+ Two full-width stacked rows on the plain panel surface, separated from
+ the editor above by a single full-bleed hairline (the same divider
+ treatment every panel section uses):
+ - fields row — `SIZE [W] × [H] px [Auto switch]` (+ TARGET select when
+ several diagrams exist), stretched across the full row. One Auto
+ switch governs both dimensions.
+ - actions row — PNG / SVG / JPEG download buttons stretched equally,
+ plus a compact "Copy Image" clipboard button, each with a small icon. */
+.export-bar {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ padding: 18px 16px;
+ background: var(--panel-bg);
+ border-top: 1px solid var(--border);
+ border-radius: 0;
+}
+.export-bar-row {
+ display: flex;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+}
+.export-bar-row-fields {
+ justify-content: space-between;
+}
+.export-bar-row-actions {
+ align-items: center;
+}
+.export-field {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
+.export-field-label {
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+/* SIZE inputs: two bordered pixel fields joined by "×", indigo focus ring;
+ dimmed while the Auto switch is on. */
+.export-size-input {
+ flex: 1;
+ min-width: 64px;
+ width: auto;
+ text-align: center;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 6px 8px;
+}
+.export-size-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.export-size-input:disabled {
+ color: var(--text-muted);
+ background: var(--surface-1);
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+.export-size-x,
+.export-size-px {
+ color: var(--text-muted);
+ font-size: 12px;
+ flex-shrink: 0;
+}
+/* Auto switch: pill with a sliding knob (mockup). aria-checked drives the
+ on/off visuals. */
+.export-auto-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 5px 12px 5px 6px;
+ background: var(--panel-bg);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+}
+.export-auto-toggle .toggle-track {
+ position: relative;
+ width: 28px;
+ height: 16px;
+ border-radius: 999px;
+ background: var(--border);
+ transition: background-color 150ms ease;
+ flex-shrink: 0;
+}
+.export-auto-toggle .toggle-knob {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+ transition: transform 150ms ease;
+}
+.export-auto-toggle[aria-checked="true"] .toggle-track {
+ background: var(--accent);
+}
+.export-auto-toggle[aria-checked="true"] .toggle-knob {
+ transform: translateX(12px);
+}
+/* (BACKGROUND option removed — exports always use a white background.) */
+.export-bar-select {
+ font-family: var(--font-sans);
+ font-size: 12px;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 5px 8px;
+ max-width: 160px;
+ flex-shrink: 0;
+}
+.export-download-btn,
+.export-copy-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ flex: 1 1 0;
+}
+.export-download-btn {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--text);
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 13px;
+}
+.export-download-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+.export-download-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.export-copy-btn {
+ flex: 0 0 auto;
+ font-family: var(--font-sans);
+ font-weight: 600;
+ font-size: 12px;
+ color: var(--text-muted);
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 13px;
+}
+.export-copy-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+.export-copy-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.export-bar-message {
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+.export-bar-message-error {
+ color: var(--error-fg);
+}
+.export-bar-message-success {
+ color: var(--status-ready-fg);
+}
+
+/* The diagram itself always renders on a white card — icons are drawn for a
+ light background, so this stays white in BOTH themes. */
+.diagram-card {
+ display: inline-block;
+ background: #ffffff;
+ border-radius: var(--radius-lg);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
+ padding: 20px;
+}
+
+/* ---------- CodeMirror search panel (Mod-F) — theme both modes ---------- */
+.editor-pane .cm-panels {
+ background: var(--panel-alt);
+ color: var(--text);
+ border-color: var(--border);
+}
+.editor-pane .cm-panels.cm-panels-top {
+ border-bottom: 1px solid var(--border);
+}
+.editor-pane .cm-panel.cm-search label {
+ color: var(--text-muted);
+}
+.editor-pane .cm-textfield {
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+}
+.editor-pane .cm-button {
+ background: var(--panel-bg);
+ background-image: none;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+}
+.editor-pane .cm-button:active {
+ background: var(--surface-1);
+}
+.editor-pane .cm-searchMatch {
+ background: rgba(245, 184, 92, 0.35);
+}
+.editor-pane .cm-searchMatch-selected {
+ background: rgba(99, 102, 241, 0.4);
+}
+
+/* ---------- CodeMirror autocomplete / tooltip chrome ---------- */
+.cm-tooltip {
+ background: var(--cm-tooltip-bg);
+ border: 1px solid var(--cm-tooltip-border);
+ border-radius: var(--radius-sm);
+ color: var(--cm-tooltip-fg);
+ font-family: var(--font-mono);
+}
+.cm-tooltip-autocomplete ul li[aria-selected] {
+ background: var(--cm-autocomplete-selected-bg) !important;
+ color: var(--cm-autocomplete-selected-fg) !important;
+}
diff --git a/playground/src/completions/imports.test.ts b/playground/src/completions/imports.test.ts
new file mode 100644
index 00000000..c596dccd
--- /dev/null
+++ b/playground/src/completions/imports.test.ts
@@ -0,0 +1,79 @@
+import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
+import { EditorState } from "@codemirror/state";
+import { describe, expect, it } from "vitest";
+import type { Catalog } from "../types";
+import { diagramsCompletions, moduleSegments, parseImports } from "./imports";
+
+function ctx(doc: string, pos = doc.length, explicit = false): CompletionContext {
+ return new CompletionContext(EditorState.create({ doc }), pos, explicit);
+}
+
+const CATALOG: Catalog = {
+ modules: {
+ "diagrams.aws.compute": [
+ { name: "EC2", aliases: [], icon: "aws/compute/ec2.png" },
+ { name: "ElasticContainerService", aliases: ["ECS"], icon: "aws/compute/x.png" },
+ ],
+ "diagrams.aws.database": [{ name: "RDS", aliases: [], icon: "aws/database/rds.png" }],
+ "diagrams.gcp.compute": [{ name: "GCE", aliases: [], icon: "gcp/compute/gce.png" }],
+ },
+ signatures: { Diagram: [], Cluster: [], Edge: [] },
+};
+
+describe("parseImports", () => {
+ it("collects plain and aliased names", () => {
+ const doc = "from diagrams import Diagram\nfrom diagrams.aws.compute import EC2, ElasticContainerService as ECS\n";
+ const names = parseImports(doc);
+ expect(names.get("Diagram")).toBe("diagrams.Diagram");
+ expect(names.get("EC2")).toBe("diagrams.aws.compute.EC2");
+ expect(names.get("ECS")).toBe("diagrams.aws.compute.ElasticContainerService");
+ });
+
+ it("ignores non-import lines", () => {
+ expect(parseImports("x = 1\n# from fake import Y\n").size).toBe(0);
+ });
+});
+
+describe("moduleSegments", () => {
+ it("lists next segments for a prefix", () => {
+ expect(moduleSegments(CATALOG, "diagrams.")).toEqual(["aws", "gcp"]);
+ expect(moduleSegments(CATALOG, "diagrams.aws.")).toEqual(["compute", "database"]);
+ });
+});
+
+describe("diagramsCompletions", () => {
+ const source = diagramsCompletions(CATALOG);
+
+ it("completes next segments after a dotted prefix", () => {
+ const r = source(ctx("from diagrams.aws.")) as CompletionResult;
+ expect(r.options.map((o) => o.label)).toEqual(["compute", "database"]);
+ expect(r.from).toBe("from diagrams.aws.".length);
+ });
+
+ it("offers the root module while typing it (no corruption)", () => {
+ const r = source(ctx("from diag")) as CompletionResult;
+ expect(r.options.map((o) => o.label)).toEqual(["diagrams"]);
+ expect(r.from).toBe("from ".length);
+ });
+
+ it("handles extra whitespace after from", () => {
+ const r = source(ctx("from diagrams.")) as CompletionResult;
+ expect(r.from).toBe("from diagrams.".length);
+ expect(r.options.map((o) => o.label)).toEqual(["aws", "gcp"]);
+ });
+
+ it("completes classes when an earlier name is aliased", () => {
+ const r = source(ctx("from diagrams.aws.compute import EC2 as E, EC")) as CompletionResult;
+ expect(r.options.some((o) => o.label === "ElasticContainerService")).toBe(true);
+ expect(r.from).toBe("from diagrams.aws.compute import EC2 as E, ".length);
+ });
+});
+
+describe("parseImports parenthesized", () => {
+ it("handles multiline parenthesized imports with aliases", () => {
+ const doc = "from diagrams.aws.compute import (\n EC2,\n ElasticContainerService as ECS,\n)\n";
+ const names = parseImports(doc);
+ expect(names.get("EC2")).toBe("diagrams.aws.compute.EC2");
+ expect(names.get("ECS")).toBe("diagrams.aws.compute.ElasticContainerService");
+ });
+});
diff --git a/playground/src/completions/imports.ts b/playground/src/completions/imports.ts
new file mode 100644
index 00000000..1b644c7c
--- /dev/null
+++ b/playground/src/completions/imports.ts
@@ -0,0 +1,112 @@
+import { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
+import type { Catalog } from "../types";
+
+const IMPORT_LINE = /^from\s+(diagrams(?:\.\w+)*)\s+import\s+(.+)$/;
+const PAREN_IMPORT = /from\s+(diagrams(?:\.\w+)*)\s+import\s*\(([^)]*)\)/g;
+
+function addImportNames(names: Map, module: string, imports: string): void {
+ for (const part of imports.split(",")) {
+ const [original, alias] = part.split(/\s+as\s+/).map((s) => s.trim());
+ if (!original || !/^\w+$/.test(original)) continue;
+ if (alias && !/^\w+$/.test(alias)) continue;
+ names.set(alias ?? original, `${module}.${original}`);
+ }
+}
+
+export function parseImports(doc: string): Map {
+ const names = new Map();
+
+ // Handle parenthesized (possibly multiline) imports first, then strip them
+ // from the doc so the per-line pass below doesn't double-process them.
+ let remaining = doc;
+ for (const match of doc.matchAll(PAREN_IMPORT)) {
+ const [whole, module, imports] = match;
+ addImportNames(names, module, imports);
+ remaining = remaining.replace(whole, "");
+ }
+
+ for (const line of remaining.split("\n")) {
+ const match = line.trim().match(IMPORT_LINE);
+ if (!match) continue;
+ const [, module, imports] = match;
+ addImportNames(names, module, imports);
+ }
+ return names;
+}
+
+export function moduleSegments(catalog: Catalog, prefix: string): string[] {
+ const segments = new Set();
+ for (const moduleName of Object.keys(catalog.modules)) {
+ const withDot = moduleName + ".";
+ if (withDot.startsWith(prefix)) {
+ const rest = moduleName.slice(prefix.length);
+ if (rest) segments.add(rest.split(".")[0]);
+ }
+ }
+ return [...segments].sort();
+}
+
+function iconInfo(icon: string): Completion["info"] {
+ return () => {
+ const img = document.createElement("img");
+ img.src = `icons/${icon}`;
+ img.width = 48;
+ img.height = 48;
+ return img;
+ };
+}
+
+export function diagramsCompletions(catalog: Catalog): CompletionSource {
+ return (context: CompletionContext): CompletionResult | null => {
+ // 1) `from diagrams.aws.` — module path segments
+ const modMatch = context.matchBefore(/from\s+[\w.]*$/);
+ if (modMatch) {
+ const typed = modMatch.text.replace(/^from\s+/, "");
+ const consumed = modMatch.text.length - typed.length; // actual "from" width
+ const lastDot = typed.lastIndexOf(".");
+ const prefix = lastDot === -1 ? "" : typed.slice(0, lastDot + 1);
+ const options = moduleSegments(catalog, prefix).map((seg) => ({
+ label: seg,
+ type: "namespace",
+ }));
+ if (!options.length) return null;
+ return { from: modMatch.from + consumed + prefix.length, options };
+ }
+
+ // 2) `from diagrams.aws.compute import EC` — class names
+ const clsMatch = context.matchBefore(
+ /from\s+(diagrams[\w.]+)\s+import\s+(?:\w+(?:\s+as\s+\w+)?\s*,\s*)*\w*$/,
+ );
+ if (clsMatch) {
+ const module = clsMatch.text.match(/from\s+([\w.]+)/)![1];
+ const classes = catalog.modules[module];
+ if (!classes) return null;
+ const word = context.matchBefore(/\w*$/)!;
+ const options: Completion[] = classes.flatMap((cls) => [
+ { label: cls.name, type: "class", info: iconInfo(cls.icon) },
+ ...cls.aliases.map((alias) => ({
+ label: alias,
+ type: "class" as const,
+ detail: cls.name,
+ info: iconInfo(cls.icon),
+ })),
+ ]);
+ return { from: word.from, options };
+ }
+
+ // 3) general position — imported names
+ const word = context.matchBefore(/\w+$/);
+ if (!word && !context.explicit) return null;
+ const imported = parseImports(context.state.doc.toString());
+ if (!imported.size) return null;
+ return {
+ from: word?.from ?? context.pos,
+ options: [...imported.entries()].map(([name, origin]) => ({
+ label: name,
+ type: "class",
+ detail: origin,
+ })),
+ validFor: /^\w*$/,
+ };
+ };
+}
diff --git a/playground/src/completions/signature.test.ts b/playground/src/completions/signature.test.ts
new file mode 100644
index 00000000..f9ed21e9
--- /dev/null
+++ b/playground/src/completions/signature.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { findCallContext, lookupSignature } from "./signature";
+
+describe("findCallContext", () => {
+ it("returns the innermost open call", () => {
+ expect(findCallContext('with Diagram("web", ')).toBe("Diagram");
+ expect(findCallContext("Edge(color=")).toBe("Edge");
+ expect(findCallContext('Cluster("db", graph_attr={')).toBe("Cluster");
+ });
+
+ it("ignores completed calls", () => {
+ expect(findCallContext('EC2("web") >> ')).toBeNull();
+ expect(findCallContext("x = 1")).toBeNull();
+ });
+
+ it("handles nesting", () => {
+ expect(findCallContext('Diagram("a", graph_attr=dict(')).toBe("dict");
+ });
+});
+
+describe("lookupSignature", () => {
+ it("returns params for known functions", () => {
+ expect(lookupSignature({ Diagram: ["name: str = ''"] }, "Diagram")).toEqual(["name: str = ''"]);
+ });
+
+ it("ignores prototype-chain names", () => {
+ expect(lookupSignature({}, "constructor")).toBeNull();
+ expect(lookupSignature({}, "toString")).toBeNull();
+ });
+
+ it("returns null for null or unknown names", () => {
+ expect(lookupSignature({ Diagram: [] }, null)).toBeNull();
+ expect(lookupSignature({ Diagram: [] }, "Edge")).toBeNull();
+ });
+});
diff --git a/playground/src/completions/signature.ts b/playground/src/completions/signature.ts
new file mode 100644
index 00000000..61e434d8
--- /dev/null
+++ b/playground/src/completions/signature.ts
@@ -0,0 +1,69 @@
+import { StateField } from "@codemirror/state";
+import { EditorView, Tooltip, showTooltip } from "@codemirror/view";
+import type { Extension } from "@codemirror/state";
+
+/** Finds the name of the innermost call that is still open (unclosed) in the
+ * text before the cursor.
+ *
+ * v1 known limitations:
+ * (a) No string-literal awareness — unbalanced brackets inside Python strings can
+ * produce a false function context (worst case: wrong/absent tooltip, never a buffer write).
+ * (b) buildTooltip is line-scoped — multiline calls lose the tooltip.
+ */
+export function findCallContext(textBeforeCursor: string): string | null {
+ const stack: string[] = [];
+ const re = /([A-Za-z_]\w*)?\s*(\(|\)|\[|\]|\{|\})/g;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(textBeforeCursor))) {
+ const [, name, bracket] = match;
+ if (bracket === "(") stack.push(name ?? "");
+ else if (bracket === "[" || bracket === "{") stack.push("");
+ else stack.pop();
+ }
+ for (let i = stack.length - 1; i >= 0; i--) {
+ if (stack[i]) return stack[i];
+ }
+ return null;
+}
+
+/** Own-property, array-checked signature lookup — guards against
+ * prototype-chain names like "constructor" or "toString". */
+export function lookupSignature(
+ signatures: Record,
+ funcName: string | null
+): string[] | null {
+ if (!funcName || !Object.hasOwn(signatures, funcName)) return null;
+ const params = signatures[funcName];
+ return Array.isArray(params) ? params : null;
+}
+
+function buildTooltip(signatures: Record, view: { state: EditorView["state"] }): Tooltip | null {
+ const { state } = view;
+ const pos = state.selection.main.head;
+ const line = state.doc.lineAt(pos);
+ const funcName = findCallContext(line.text.slice(0, pos - line.from));
+ const params = lookupSignature(signatures, funcName);
+ if (!funcName || !params) return null;
+ return {
+ pos,
+ above: true,
+ create: () => {
+ const dom = document.createElement("div");
+ dom.className = "cm-signature-hint";
+ dom.textContent = `${funcName}(${params.join(", ")})`;
+ return { dom };
+ },
+ };
+}
+
+export function signatureTooltip(signatures: Record): Extension {
+ const field = StateField.define({
+ create: (state) => buildTooltip(signatures, { state }),
+ update(value, tr) {
+ if (!tr.docChanged && !tr.selection) return value;
+ return buildTooltip(signatures, { state: tr.state });
+ },
+ provide: (f) => showTooltip.from(f),
+ });
+ return [field];
+}
diff --git a/playground/src/components/DragHandle.tsx b/playground/src/components/DragHandle.tsx
new file mode 100644
index 00000000..96c455fb
--- /dev/null
+++ b/playground/src/components/DragHandle.tsx
@@ -0,0 +1,59 @@
+import { useRef, useState, type PointerEvent, type RefObject } from "react";
+
+interface Props {
+ containerRef: RefObject;
+ onChange: (value: number) => void;
+ ariaLabel: string;
+ /** Derives the next value from the pointer's clientX and containerRef's
+ * current bounding rect — ratio math for the editor/preview split, px
+ * math for the sidebar. Callers own their own clamping. */
+ valueFromPointer: (clientX: number, rect: DOMRect) => number;
+ /** Value reported on double-click, and the fallback reported mid-drag if
+ * containerRef briefly has no rect (e.g. mid-unmount). */
+ resetValue: number;
+}
+
+// Shared flush 1px vertical divider / drag handle: used both between
+// .editor-column and .preview-pane (ratio math, 25-75 clamp) and on the
+// node-list sidebar's right edge (px math, clampSidebarWidth). Drags
+// compute the next value via the caller-supplied valueFromPointer and
+// report it through onChange; double-click resets to resetValue.
+export default function DragHandle({ containerRef, onChange, ariaLabel, valueFromPointer, resetValue }: Props) {
+ const [active, setActive] = useState(false);
+ const draggingRef = useRef(false);
+
+ function handlePointerDown(e: PointerEvent) {
+ draggingRef.current = true;
+ setActive(true);
+ e.currentTarget.setPointerCapture(e.pointerId);
+ }
+
+ function handlePointerMove(e: PointerEvent) {
+ if (!draggingRef.current) return;
+ const rect = containerRef.current?.getBoundingClientRect();
+ onChange(rect ? valueFromPointer(e.clientX, rect) : resetValue);
+ }
+
+ function endDrag(e: PointerEvent) {
+ if (!draggingRef.current) return;
+ draggingRef.current = false;
+ setActive(false);
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ }
+
+ return (
+ onChange(resetValue)}
+ />
+ );
+}
diff --git a/playground/src/components/EditorPane.tsx b/playground/src/components/EditorPane.tsx
new file mode 100644
index 00000000..eafe3abb
--- /dev/null
+++ b/playground/src/components/EditorPane.tsx
@@ -0,0 +1,102 @@
+import { indentWithTab } from "@codemirror/commands";
+import { python } from "@codemirror/lang-python";
+import { Extension } from "@codemirror/state";
+import { EditorView, keymap } from "@codemirror/view";
+import { basicSetup } from "codemirror";
+import { useEffect, useRef } from "react";
+import { blueprintTheme } from "../editor-theme";
+
+interface Props {
+ initialCode: string;
+ extensions?: Extension[];
+ lineCount: number;
+ onChange: (code: string) => void;
+ onReplaceRef?: (replace: (code: string) => void) => void;
+ /** Mod-Enter: run the current code immediately (skips the debounce). */
+ onRunNow?: () => void;
+ /** Mod-S: share (intercepts the browser's save dialog). */
+ onShare?: () => void;
+}
+
+export default function EditorPane({
+ initialCode,
+ extensions = [],
+ lineCount,
+ onChange,
+ onReplaceRef,
+ onRunNow,
+ onShare,
+}: Props) {
+ const hostRef = useRef
(null);
+ // Keep the latest callbacks without remounting the view (avoids stale
+ // closures if prop identities change after mount).
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+ const onRunNowRef = useRef(onRunNow);
+ onRunNowRef.current = onRunNow;
+ const onShareRef = useRef(onShare);
+ onShareRef.current = onShare;
+
+ // NOTE: `extensions` and `initialCode` are intentionally captured once at
+ // mount — the App mounts EditorPane only after the catalog is loaded, so
+ // extensions are stable for the lifetime of the view.
+ useEffect(() => {
+ const view = new EditorView({
+ doc: initialCode,
+ parent: hostRef.current!,
+ extensions: [
+ // Playground-level bindings, ahead of basicSetup so they win:
+ // Mod-Enter = run now (skip debounce), Mod-S = share link instead of
+ // the browser's useless save dialog.
+ keymap.of([
+ {
+ key: "Mod-Enter",
+ preventDefault: true,
+ run: () => {
+ onRunNowRef.current?.();
+ return true;
+ },
+ },
+ {
+ key: "Mod-s",
+ preventDefault: true,
+ run: () => {
+ onShareRef.current?.();
+ return true;
+ },
+ },
+ ]),
+ basicSetup,
+ // basicSetup's default keymap doesn't bind Tab (that's deliberate
+ // upstream, so Tab still moves focus for a11y by default) — add it
+ // back explicitly so Tab indents code, matching every code editor's
+ // expected behavior here. autocompletion's own Tab-to-accept binding
+ // (wired in App.tsx via `extensions`, spread in below) still wins
+ // while a completion popup is open — CodeMirror gives it higher
+ // precedence internally — so this only fires when no popup is open.
+ keymap.of([indentWithTab]),
+ python(),
+ blueprintTheme,
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) onChangeRef.current(update.state.doc.toString());
+ }),
+ ...extensions,
+ ],
+ });
+ onReplaceRef?.((code) => {
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: code } });
+ });
+ return () => view.destroy();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+ main.py
+ {lineCount} lines
+
+
+
+ );
+}
diff --git a/playground/src/components/ErrorPanel.tsx b/playground/src/components/ErrorPanel.tsx
new file mode 100644
index 00000000..2a13bf7a
--- /dev/null
+++ b/playground/src/components/ErrorPanel.tsx
@@ -0,0 +1,13 @@
+interface Props {
+ error: string | null;
+ testId?: string;
+}
+
+export default function ErrorPanel({ error, testId = "error-panel" }: Props) {
+ if (!error) return null;
+ return (
+
+ {error}
+
+ );
+}
diff --git a/playground/src/components/ExamplesGallery.tsx b/playground/src/components/ExamplesGallery.tsx
new file mode 100644
index 00000000..fd934444
--- /dev/null
+++ b/playground/src/components/ExamplesGallery.tsx
@@ -0,0 +1,23 @@
+import { EXAMPLES } from "../examples";
+
+interface Props {
+ activeExample: string | null;
+ onSelect: (example: { title: string; code: string }) => void;
+}
+
+export default function ExamplesGallery({ activeExample, onSelect }: Props) {
+ return (
+
+ Examples
+ {EXAMPLES.map((example) => (
+ onSelect(example)}
+ >
+ {example.title}
+
+ ))}
+
+ );
+}
diff --git a/playground/src/components/ExportBar.tsx b/playground/src/components/ExportBar.tsx
new file mode 100644
index 00000000..a76784b0
--- /dev/null
+++ b/playground/src/components/ExportBar.tsx
@@ -0,0 +1,249 @@
+import { useEffect, useState } from "react";
+import { download, inlineIcons, svgToPngBlob } from "../export/exporter";
+import type { FocusEvent, KeyboardEvent } from "react";
+
+type DownloadFormat = "png" | "svg" | "jpeg";
+
+const MIN_DIM = 16;
+const MAX_DIM = 8192;
+const COPIED_TIMEOUT_MS = 2000;
+
+interface Props {
+ svgs: { name: string; svg: string }[];
+}
+
+function slug(name: string): string {
+ return name ? name.toLowerCase().replace(/\W+/g, "_") : "diagram";
+}
+
+// Parses a raw SIZE field's text into a positive pixel value. Empty (or
+// not-yet-a-number, e.g. mid-typing) means "no override" — resolveSize's
+// aspect-ratio-preserving default takes over for that axis. Clamping to
+// [MIN_DIM, MAX_DIM] happens separately on blur (see `clampDimText`).
+function parseDim(raw: string): number | undefined {
+ if (raw.trim() === "") return undefined;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : undefined;
+}
+
+function clampDimText(raw: string): string {
+ if (raw.trim() === "") return "";
+ const n = Math.round(Number(raw));
+ if (!Number.isFinite(n)) return "";
+ return String(Math.min(MAX_DIM, Math.max(MIN_DIM, n)));
+}
+
+// Small download-arrow icon rendered to the right of each format button's
+// label. Purely decorative — the button's accessible name still comes from
+// its text content ("PNG"/"SVG"/"JPEG"), which e2e depends on — so this
+// stays aria-hidden.
+function DownloadIcon() {
+ return (
+
+
+
+
+ );
+}
+
+// Small copy icon rendered LEFT of "Copy" (mockup order) — decorative and
+// aria-hidden for the same reason as `DownloadIcon`.
+function CopyIcon() {
+ return (
+
+
+
+
+ );
+}
+
+// Permanently-visible export bar docked under the editor (Mermaid-Live
+// style). Two full-width rows:
+// - fields row: `SIZE [W] × [H] px [Auto toggle]` stretched across the
+// full width (+ TARGET select when several diagrams exist). One Auto
+// switch governs BOTH dimensions: ON = inputs disabled/dimmed and the
+// exporter's aspect-preserving default (2x) applies; OFF = explicit
+// pixel inputs (either axis may be left empty for per-axis aspect
+// auto). Toggling Auto back on keeps the last typed values.
+// - actions row: PNG / SVG / JPEG download buttons (equal width) and a
+// compact "Copy Image" (clipboard PNG) button. Exports always use a
+// white background (the BACKGROUND option was dropped).
+export default function ExportBar({ svgs }: Props) {
+ const [auto, setAuto] = useState(true);
+ const [width, setWidth] = useState("");
+ const [height, setHeight] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ // Keep the selection valid as the diagram list changes (e.g. a script edit
+ // that drops from two `with Diagram(...)` blocks down to one).
+ useEffect(() => {
+ if (selectedIndex >= svgs.length && svgs.length > 0) setSelectedIndex(0);
+ }, [svgs.length, selectedIndex]);
+
+ const active = svgs[selectedIndex] ?? null;
+ const effectiveWidth = auto ? undefined : parseDim(width);
+ const effectiveHeight = auto ? undefined : parseDim(height);
+
+ function handleDimBlur(setter: (v: string) => void) {
+ return (e: FocusEvent) => setter(clampDimText(e.currentTarget.value));
+ }
+
+ function handleDimKeyDown(e: KeyboardEvent) {
+ // Commit + clamp on Enter rather than waiting for blur, so a keyboard
+ // user can confirm the value without tabbing away.
+ if (e.key === "Enter") e.currentTarget.blur();
+ }
+
+ async function handleDownload(format: DownloadFormat) {
+ if (!active) return;
+ setError(null);
+ setCopied(false);
+ try {
+ const fileSlug = slug(active.name);
+ if (format === "svg") {
+ // SVG is vector — SIZE is meaningless for it.
+ const inlined = await inlineIcons(active.svg);
+ download(`${fileSlug}.svg`, new Blob([inlined], { type: "image/svg+xml" }));
+ } else {
+ const mime = format === "jpeg" ? "image/jpeg" : "image/png";
+ const blob = await svgToPngBlob(active.svg, {
+ width: effectiveWidth,
+ height: effectiveHeight,
+ mime,
+ });
+ download(`${fileSlug}.${format === "jpeg" ? "jpg" : "png"}`, blob);
+ }
+ } catch (err) {
+ setError(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ async function handleCopyImage() {
+ if (!active) return;
+ setError(null);
+ try {
+ const blob = await svgToPngBlob(active.svg, {
+ width: effectiveWidth,
+ height: effectiveHeight,
+ mime: "image/png",
+ });
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS);
+ } catch (err) {
+ setCopied(false);
+ setError(`Copy failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ const message = error
+ ? { text: error, tone: "error" as const }
+ : copied
+ ? { text: "Copied!", tone: "success" as const }
+ : null;
+
+ return (
+
+
+
+
+ void handleDownload("png")}
+ >
+ PNG
+
+
+ void handleDownload("svg")}
+ >
+ SVG
+
+
+ void handleDownload("jpeg")}
+ >
+ JPEG
+
+
+ void handleCopyImage()}>
+
+ Copy Image
+
+ {message && {message.text} }
+
+
+ );
+}
diff --git a/playground/src/components/NodeSearch.tsx b/playground/src/components/NodeSearch.tsx
new file mode 100644
index 00000000..b1a71ca2
--- /dev/null
+++ b/playground/src/components/NodeSearch.tsx
@@ -0,0 +1,297 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { providerLabel } from "../search/providerLabel";
+import { searchCatalog, type SearchHit } from "../search/search";
+import { catalogTree } from "../search/tree";
+import type { Catalog } from "../types";
+
+interface Props {
+ catalog: Catalog | null;
+ onInsert: (importStmt: string) => void;
+ /** Resizable sidebar width in px (see DragHandle); falls back to CSS. */
+ width?: number;
+}
+
+interface MenuHit {
+ name: string;
+ importStmt: string;
+}
+
+interface MenuState {
+ x: number;
+ y: number;
+ hit: MenuHit;
+}
+
+interface HitRowProps {
+ icon: string;
+ name: string;
+ importStmt: string;
+ module?: string;
+ onInsert: (importStmt: string) => void;
+ onContextMenu: (x: number, y: number, hit: MenuHit) => void;
+ // Set only when rendered as a class row inside an expanded tree category
+ // (depth 2); the container carries the indent/rail, this only tweaks the
+ // row's own padding via the "is-tree-class" CSS class.
+ inTree?: boolean;
+}
+
+// Shared row for a single class: real node icon (bare, no wrapper box),
+// name, optional module path. Fixed layout that never shifts on hover —
+// used by both the flat search results and the expanded-category tree rows
+// so the two views stay visually unified. Left-click inserts the import;
+// right-click opens a small custom context menu (Copy/Insert) owned by the
+// parent NodeSearch.
+function HitRow({ icon, name, importStmt, module, onInsert, onContextMenu, inTree }: HitRowProps) {
+ return (
+ onInsert(importStmt)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onInsert(importStmt);
+ }
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ onContextMenu(e.clientX, e.clientY, { name, importStmt });
+ }}
+ >
+
+
+
+ {name}
+ {module !== undefined && {module} }
+
+ );
+}
+
+interface ContextMenuProps {
+ x: number;
+ y: number;
+ hit: MenuHit;
+ onInsert: (importStmt: string) => void;
+ onClose: () => void;
+}
+
+// Small custom right-click menu: Copy import / Insert import. Rendered via a
+// portal straight onto — several ancestors (e.g. `.node-search`'s
+// fade-slide entrance animation) end their animation with a lingering
+// `transform`, which per spec establishes a new containing block for any
+// `position: fixed` descendant. Left in place, that made the menu position
+// itself relative to the sidebar instead of the viewport (appearing far from
+// the click) and clipped it under `.node-search`'s `overflow: hidden` and
+// the editor's stacking context (covered instead of on top). Portaling to
+// `document.body` sidesteps all of that: `position: fixed` is now always
+// viewport-relative, and a high z-index guarantees it paints above the
+// editor. Closes on outside pointerdown, Esc, or scroll (capture-phase
+// listeners so scrolling inside the results list — which doesn't bubble —
+// still closes it); these still work unchanged since `rootRef` points at the
+// real portaled DOM node regardless of where in the tree it renders.
+function ContextMenu({ x, y, hit, onInsert, onClose }: ContextMenuProps) {
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ function handlePointerDown(e: PointerEvent) {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose();
+ }
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ function handleScroll() {
+ onClose();
+ }
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("scroll", handleScroll, true);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("scroll", handleScroll, true);
+ };
+ }, [onClose]);
+
+ return createPortal(
+
+ {
+ void navigator.clipboard.writeText(hit.importStmt).catch(() => {});
+ onClose();
+ }}
+ >
+ Copy import
+
+ {
+ onInsert(hit.importStmt);
+ onClose();
+ }}
+ >
+ Insert import
+
+
,
+ document.body
+ );
+}
+
+const MENU_WIDTH = 160;
+const MENU_HEIGHT = 76;
+
+// Centered SVG chevron shared by both tree levels. Unlike the old text
+// glyphs ("▸"/"▾"), the triangle is geometrically centered in its box, so
+// the CSS 90° open-rotation spins in place instead of drifting (the glyph's
+// off-center metrics were visibly shifting the depth-1 caret mid-turn).
+function Chevron() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default function NodeSearch({ catalog, onInsert, width }: Props) {
+ const [query, setQuery] = useState("");
+ const [expanded, setExpanded] = useState>(new Set());
+ const [menu, setMenu] = useState(null);
+
+ const hits = useMemo(
+ () => (catalog ? searchCatalog(catalog, query) : []),
+ [catalog, query]
+ );
+ const tree = useMemo(() => (catalog ? catalogTree(catalog) : []), [catalog]);
+
+ function toggle(key: string) {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }
+
+ // One menu instance at a time: keep it fully on-screen by clamping against
+ // the viewport rather than letting it render past the right/bottom edge.
+ function openMenu(x: number, y: number, hit: MenuHit) {
+ const clampedX = Math.min(x, window.innerWidth - MENU_WIDTH - 8);
+ const clampedY = Math.min(y, window.innerHeight - MENU_HEIGHT - 8);
+ setMenu({ x: Math.max(8, clampedX), y: Math.max(8, clampedY), hit });
+ }
+
+ // Flat search-result row (non-empty query).
+ function hitRow(hit: SearchHit) {
+ return (
+
+ );
+ }
+
+ const isBlank = !query.trim();
+
+ return (
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ data-testid="node-search-input"
+ />
+
+ {isBlank ? (
+
+ {tree.map((provider) => {
+ const providerKey = `provider:${provider.provider}`;
+ const providerOpen = expanded.has(providerKey);
+ return (
+
+ toggle(providerKey)}
+ >
+
+ {providerLabel(provider.provider)}
+ {provider.count}
+
+ {providerOpen && (
+
+ {provider.categories.map((cat) => {
+ const categoryKey = `category:${cat.module}`;
+ const categoryOpen = expanded.has(categoryKey);
+ const label = cat.category || cat.module.split(".").pop() || cat.module;
+ return (
+
+ toggle(categoryKey)}
+ >
+
+ {label}
+ {cat.classes.length}
+
+ {categoryOpen && (
+
+ {cat.classes.map((cls) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
{hits.map((hit) => hitRow(hit))}
+ )}
+ {menu && (
+
setMenu(null)}
+ />
+ )}
+
+ );
+}
diff --git a/playground/src/components/PreviewPane.tsx b/playground/src/components/PreviewPane.tsx
new file mode 100644
index 00000000..98e60cd1
--- /dev/null
+++ b/playground/src/components/PreviewPane.tsx
@@ -0,0 +1,337 @@
+import type { KeyboardEvent, ReactNode } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { svgStats } from "../utils/svgStats";
+import { fitView, zoomAt, type ViewTransform } from "../utils/zoom";
+
+interface Props {
+ svgs: { name: string; svg: string }[];
+ loading: boolean;
+ renderMs: number | null;
+ onRenameDiagram?: (index: number, name: string) => void;
+ /** Rendered docked at the bottom of the pane, below the canvas
+ * (the ExportBar lives here per the design mockup). */
+ children?: ReactNode;
+}
+
+// True infinite canvas: the container is a fixed, overflow:hidden viewport;
+// all pan/zoom state lives in `view` (tx/ty/scale) and is applied as a
+// single CSS transform on the absolutely-positioned content layer. Unlike
+// the old scrollLeft/scrollTop-based pan, tx/ty are unbounded — content can
+// be dragged past the left/top edge (negative translate) with no clamping.
+export default function PreviewPane({ svgs, loading, renderMs, onRenameDiagram, children }: Props) {
+ const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
+ const containerRef = useRef(null);
+ const contentRef = useRef(null);
+ const viewRef = useRef(view);
+ viewRef.current = view;
+
+ // Click-to-edit for the fixed header's diagram name (always index 0 — the
+ // per-sheet `.sheet-label`s below stay read-only). `titleDraft` is local
+ // input state so typing doesn't touch `svgs`/App state until commit.
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
+ const [titleDraft, setTitleDraft] = useState("");
+ const titleInputRef = useRef(null);
+
+ useEffect(() => {
+ if (!isEditingTitle) return;
+ const input = titleInputRef.current;
+ input?.focus();
+ input?.select();
+ }, [isEditingTitle]);
+
+ function startEditingTitle() {
+ if (!onRenameDiagram) return;
+ setTitleDraft(svgs[0]?.name ?? "");
+ setIsEditingTitle(true);
+ }
+
+ function commitTitleEdit() {
+ setIsEditingTitle(false);
+ const nextName = titleDraft;
+ const previousName = svgs[0]?.name ?? "";
+ if (nextName !== previousName) onRenameDiagram?.(0, nextName);
+ }
+
+ function handleTitleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitTitleEdit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setIsEditingTitle(false);
+ }
+ }
+
+ // Fits the content layer entirely inside the canvas viewport (scaled down
+ // for large diagrams, scaled up — capped at 2x — for tiny ones) and
+ // centers it on both axes. offsetWidth/Height read the content's
+ // untransformed layout size (CSS transforms don't affect layout), so this
+ // is correct regardless of the view's current scale. Used both for the
+ // initial/on-new-svgs sizing and for the "%" fit button.
+ const fitToView = useCallback(() => {
+ const container = containerRef.current;
+ const content = contentRef.current;
+ if (!container || !content) return;
+ const rect = container.getBoundingClientRect();
+ setView(fitView(rect.width, rect.height, content.offsetWidth, content.offsetHeight));
+ }, []);
+
+ useLayoutEffect(() => {
+ fitToView();
+ }, [svgs, fitToView]);
+
+ // ctrl+wheel (trackpad pinch) zooms at the cursor; a plain wheel pans
+ // (natural two-finger trackpad scroll). Must be a real (non-passive) DOM
+ // listener — not React's onWheel — so preventDefault() actually stops the
+ // browser's own page-zoom/scroll for the gesture.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ function handleWheel(e: WheelEvent) {
+ e.preventDefault();
+ const rect = container!.getBoundingClientRect();
+ if (e.ctrlKey) {
+ const factor = Math.exp(-e.deltaY * 0.01);
+ setView((v) => zoomAt(v, e.clientX - rect.left, e.clientY - rect.top, factor));
+ } else {
+ setView((v) => ({ ...v, tx: v.tx - e.deltaX, ty: v.ty - e.deltaY }));
+ }
+ }
+ container.addEventListener("wheel", handleWheel, { passive: false });
+ return () => container.removeEventListener("wheel", handleWheel);
+ }, []);
+
+ // Two-pointer touch pinch: track active pointers' positions, and on each
+ // move with exactly two active pointers, derive a scale factor from the
+ // change in distance between them and zoom at their midpoint.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const pointers = new Map();
+ let lastDistance: number | null = null;
+
+ function distance(): number {
+ const [a, b] = [...pointers.values()];
+ return Math.hypot(a.x - b.x, a.y - b.y);
+ }
+ function midpoint(): { x: number; y: number } {
+ const [a, b] = [...pointers.values()];
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ }
+
+ function handlePointerDown(e: PointerEvent) {
+ if (e.pointerType !== "touch") return;
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ lastDistance = pointers.size === 2 ? distance() : null;
+ }
+ function handlePointerMove(e: PointerEvent) {
+ if (!pointers.has(e.pointerId)) return;
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pointers.size !== 2) return;
+ const dist = distance();
+ if (lastDistance === null || lastDistance === 0) {
+ lastDistance = dist;
+ return;
+ }
+ const factor = dist / lastDistance;
+ const { x, y } = midpoint();
+ const rect = container!.getBoundingClientRect();
+ setView((v) => zoomAt(v, x - rect.left, y - rect.top, factor));
+ lastDistance = dist;
+ }
+ function handlePointerEnd(e: PointerEvent) {
+ pointers.delete(e.pointerId);
+ lastDistance = pointers.size === 2 ? distance() : null;
+ }
+
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("pointermove", handlePointerMove);
+ container.addEventListener("pointerup", handlePointerEnd);
+ container.addEventListener("pointercancel", handlePointerEnd);
+ container.addEventListener("pointerleave", handlePointerEnd);
+ return () => {
+ container.removeEventListener("pointerdown", handlePointerDown);
+ container.removeEventListener("pointermove", handlePointerMove);
+ container.removeEventListener("pointerup", handlePointerEnd);
+ container.removeEventListener("pointercancel", handlePointerEnd);
+ container.removeEventListener("pointerleave", handlePointerEnd);
+ };
+ }, []);
+
+ // One-pointer drag-to-pan: tx/ty move freely with the pointer — no bounds,
+ // so dragging toward the left/top can carry content into negative
+ // translate space (this is exactly what fixes "can't pan left/up past the
+ // edge" from the old scrollLeft/scrollTop approach). A 3px move threshold
+ // keeps incidental clicks (zoom buttons, etc.) from starting a drag; those
+ // targets are also excluded outright. If a second pointer comes down
+ // mid-drag, pan disengages immediately and hands off to the pinch effect.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ let draggingId: number | null = null;
+ let engaged = false;
+ let originX = 0;
+ let originY = 0;
+ let originTx = 0;
+ let originTy = 0;
+
+ function disengage() {
+ if (draggingId !== null && container!.hasPointerCapture(draggingId)) {
+ container!.releasePointerCapture(draggingId);
+ }
+ container!.classList.remove("is-panning");
+ draggingId = null;
+ engaged = false;
+ }
+
+ function handlePointerDown(e: PointerEvent) {
+ if (draggingId !== null) {
+ disengage();
+ return;
+ }
+ if (e.button !== 0) return;
+ if ((e.target as Element).closest("button, a, input, select, [role=menu]")) return;
+ draggingId = e.pointerId;
+ engaged = false;
+ originX = e.clientX;
+ originY = e.clientY;
+ originTx = viewRef.current.tx;
+ originTy = viewRef.current.ty;
+ container!.setPointerCapture(draggingId);
+ }
+
+ function handlePointerMove(e: PointerEvent) {
+ if (draggingId === null || e.pointerId !== draggingId) return;
+ const dx = e.clientX - originX;
+ const dy = e.clientY - originY;
+ if (!engaged) {
+ if (Math.hypot(dx, dy) < 3) return;
+ engaged = true;
+ container!.classList.add("is-panning");
+ }
+ setView((v) => ({ ...v, tx: originTx + dx, ty: originTy + dy }));
+ }
+
+ function handlePointerEnd(e: PointerEvent) {
+ if (draggingId !== e.pointerId) return;
+ disengage();
+ }
+
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("pointermove", handlePointerMove);
+ container.addEventListener("pointerup", handlePointerEnd);
+ container.addEventListener("pointercancel", handlePointerEnd);
+ return () => {
+ container.removeEventListener("pointerdown", handlePointerDown);
+ container.removeEventListener("pointermove", handlePointerMove);
+ container.removeEventListener("pointerup", handlePointerEnd);
+ container.removeEventListener("pointercancel", handlePointerEnd);
+ };
+ }, []);
+
+ // Zoom segment (−/+) buttons zoom around the container's center.
+ function zoomByFactor(factor: number) {
+ const container = containerRef.current;
+ if (!container) return;
+ const rect = container.getBoundingClientRect();
+ setView((v) => zoomAt(v, rect.width / 2, rect.height / 2, factor));
+ }
+
+ const pct = Math.round(view.scale * 100);
+ const first = svgs[0];
+ const firstStats = first ? svgStats(first.svg) : null;
+ const showSheetLabels = svgs.length > 1;
+
+ return (
+
+
+
+ {isEditingTitle ? (
+ setTitleDraft(e.target.value)}
+ onBlur={commitTitleEdit}
+ onKeyDown={handleTitleKeyDown}
+ />
+ ) : (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ startEditingTitle();
+ }
+ }
+ : undefined
+ }
+ title={onRenameDiagram ? "Click to rename" : undefined}
+ >
+ {first?.name || "diagram"}
+ {onRenameDiagram && (
+
+ ✎
+
+ )}
+
+ )}
+ {firstStats && (
+
+ · {firstStats.nodes} nodes · {firstStats.edges} edges
+
+ )}
+
+
+
+ zoomByFactor(1 / 1.2)} aria-label="Zoom out">
+ −
+
+
+ {pct}%
+
+ zoomByFactor(1.2)} aria-label="Zoom in">
+ +
+
+
+
+
+ {/* testid lives on the canvas (not the pane root) so e2e's
+ `preview svg` matches only rendered diagram SVGs — the ExportBar
+ docked below carries its own decorative icon
s. */}
+
+
+ {svgs.map(({ name, svg }, i) => (
+
+ {showSheetLabels && {name || "diagram"}
}
+
+
+ ))}
+
+ {loading &&
Rendering… }
+ {renderMs != null &&
rendered in {renderMs}ms }
+
+ {children}
+
+ );
+}
diff --git a/playground/src/components/Toolbar.tsx b/playground/src/components/Toolbar.tsx
new file mode 100644
index 00000000..f29de8cb
--- /dev/null
+++ b/playground/src/components/Toolbar.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useState } from "react";
+import { formatStars } from "../utils/format";
+import { toggleTheme } from "../utils/theme";
+
+interface Props {
+ status: string;
+ onShare: () => void;
+ shared: boolean;
+}
+
+function statusVariant(status: string): "ready" | "busy" | "error" {
+ if (status === "Ready") return "ready";
+ if (status.startsWith("Failed")) return "error";
+ return "busy";
+}
+
+const STARS_CACHE_KEY = "dgp-gh-stars";
+const STARS_TTL_MS = 60 * 60 * 1000; // 1h
+const STARS_API_URL = "https://api.github.com/repos/mingrammer/diagrams";
+// The badge must always render — when the API is unreachable (e.g. rate
+// limited) and no cache exists yet, fall back to this approximate count;
+// it self-corrects on the next successful fetch.
+const FALLBACK_STARS = 42_500;
+
+interface StarsCache {
+ count: number;
+ ts: number;
+}
+
+function readCachedStars(ignoreTtl = false): number | null {
+ try {
+ const raw = localStorage.getItem(STARS_CACHE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StarsCache;
+ if (typeof parsed.count !== "number" || typeof parsed.ts !== "number") return null;
+ if (!ignoreTtl && Date.now() - parsed.ts > STARS_TTL_MS) return null;
+ return parsed.count;
+ } catch {
+ return null;
+ }
+}
+
+// GitHub's official octocat mark, inlined so it renders crisp at 16px with
+// no extra request and follows `currentColor` in both themes.
+function GitHubMark() {
+ return (
+
+
+
+ );
+}
+
+// Small filled star, used ahead of the formatted count.
+function StarMark() {
+ return (
+
+
+
+ );
+}
+
+export default function Toolbar({ status, onShare, shared }: Props) {
+ const isLoading = status === "Rendering…";
+ const variant = statusVariant(status);
+ // Stale-while-error: fresh cache → stale cache (expired TTL) → baked-in
+ // fallback, so the count is always visible; a successful fetch replaces it.
+ const [stars, setStars] = useState(
+ () => readCachedStars() ?? readCachedStars(true) ?? FALLBACK_STARS
+ );
+
+ useEffect(() => {
+ if (readCachedStars() !== null) return; // fresh cache already applied above
+ let cancelled = false;
+ fetch(STARS_API_URL)
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ })
+ .then((data: { stargazers_count?: unknown }) => {
+ if (cancelled) return;
+ const count = data.stargazers_count;
+ if (typeof count !== "number") return;
+ setStars(count);
+ localStorage.setItem(STARS_CACHE_KEY, JSON.stringify({ count, ts: Date.now() } satisfies StarsCache));
+ })
+ .catch(() => {
+ // Fetch failed (network, rate limit, bad shape): keep whatever count
+ // is already displayed — a fresh/stale cached value or the baked-in
+ // fallback — rather than surfacing an error.
+ });
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {/* The real diagrams project logo (copied from assets/img/diagrams.png)
+ on a white chip so its dark strokes stay legible in dark theme. */}
+
+
+
+ Diagrams Playground
+
+
+ {status}
+
+
+
+
+ );
+}
diff --git a/playground/src/editor-theme.ts b/playground/src/editor-theme.ts
new file mode 100644
index 00000000..f487e450
--- /dev/null
+++ b/playground/src/editor-theme.ts
@@ -0,0 +1,98 @@
+import type { Extension } from "@codemirror/state";
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { EditorView } from "@codemirror/view";
+import { tags as t } from "@lezer/highlight";
+
+// Blueprint engineering drawing theme for CodeMirror.
+// Static module-level constant — imported once into EditorPane's static
+// extension list at mount time (see HARD CONSTRAINT #4 in the redesign spec).
+//
+// Every color below is a var(--cm-*)/var(--syn-*) reference into app.css's
+// per-theme token blocks (:root[data-theme="dark"|"light"]). CodeMirror's
+// theme values are plain CSS strings, so var() resolves live against
+// document.documentElement's data-theme attribute — flipping the theme
+// re-paints the editor instantly with no remount required.
+
+const blueprintEditorTheme = EditorView.theme(
+ {
+ "&": {
+ backgroundColor: "var(--cm-bg)",
+ color: "var(--cm-fg)",
+ height: "100%",
+ },
+ ".cm-content": {
+ caretColor: "var(--cm-caret)",
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ fontSize: "13px",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "var(--cm-caret)",
+ borderLeftWidth: "2px",
+ },
+ // `!important` is required here: @codemirror/view's own baseTheme (part
+ // of basicSetup) ships a same-named `&dark.cm-focused > .cm-scroller >
+ // .cm-selectionLayer .cm-selectionBackground` rule with strictly higher
+ // selector specificity (it targets the internal DOM chain, not just the
+ // class) that otherwise wins the cascade and paints an opaque `#233`
+ // regardless of this token's value or insertion order — that mismatch,
+ // not the token color itself, was the actual cause of "selection too
+ // dark/opaque" (confirmed via getComputedStyle: it read `rgb(34,51,51)`
+ // — CodeMirror's literal default — in both app themes before this fix).
+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
+ backgroundColor: "var(--cm-selection) !important",
+ },
+ ".cm-activeLine": {
+ backgroundColor: "var(--cm-active-line)",
+ },
+ ".cm-gutters": {
+ backgroundColor: "var(--cm-gutter-bg)",
+ color: "var(--cm-gutter-fg)",
+ border: "none",
+ borderRight: "1px solid var(--cm-gutter-border)",
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "var(--cm-active-gutter-bg)",
+ color: "var(--cm-active-gutter-fg)",
+ },
+ ".cm-lineNumbers .cm-gutterElement": {
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ },
+ ".cm-selectionMatch": {
+ backgroundColor: "var(--cm-selection-match)",
+ },
+ ".cm-matchingBracket, .cm-nonmatchingBracket": {
+ backgroundColor: "var(--cm-matching-bracket-bg)",
+ outline: "1px solid var(--cm-matching-bracket-outline)",
+ },
+ ".cm-tooltip": {
+ backgroundColor: "var(--cm-tooltip-bg)",
+ border: "1px solid var(--cm-tooltip-border)",
+ color: "var(--cm-tooltip-fg)",
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ },
+ ".cm-tooltip-autocomplete ul li[aria-selected]": {
+ backgroundColor: "var(--cm-autocomplete-selected-bg)",
+ color: "var(--cm-autocomplete-selected-fg)",
+ },
+ },
+ { dark: true }
+);
+
+const blueprintHighlightStyle = HighlightStyle.define([
+ { tag: t.keyword, color: "var(--syn-keyword)" },
+ { tag: [t.string, t.special(t.string)], color: "var(--syn-string)" },
+ { tag: [t.comment, t.lineComment, t.blockComment, t.docComment], color: "var(--syn-comment)", fontStyle: "italic" },
+ {
+ tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.className, t.definition(t.className)],
+ color: "var(--syn-function)",
+ },
+ { tag: [t.number, t.integer, t.float], color: "var(--syn-number)" },
+ { tag: [t.operator, t.punctuation, t.bracket], color: "var(--syn-operator)" },
+ { tag: t.variableName, color: "var(--syn-variable)" },
+ { tag: t.propertyName, color: "var(--syn-property)" },
+ { tag: [t.bool, t.null], color: "var(--syn-bool)" },
+ { tag: t.definition(t.variableName), color: "var(--syn-variable)" },
+ { tag: t.atom, color: "var(--syn-atom)" },
+]);
+
+export const blueprintTheme: Extension = [blueprintEditorTheme, syntaxHighlighting(blueprintHighlightStyle)];
diff --git a/playground/src/examples.ts b/playground/src/examples.ts
new file mode 100644
index 00000000..a738e455
--- /dev/null
+++ b/playground/src/examples.ts
@@ -0,0 +1,80 @@
+export const DEFAULT_CODE = `from diagrams import Diagram
+from diagrams.aws.compute import EC2
+from diagrams.aws.database import RDS
+from diagrams.aws.network import ELB
+
+with Diagram("Web Service", show=False):
+ ELB("lb") >> EC2("web") >> RDS("userdb")
+`;
+
+export const EXAMPLES: { title: string; code: string }[] = [
+ { title: "Web Service", code: DEFAULT_CODE },
+ {
+ title: "Grouped Workers",
+ code: `from diagrams import Diagram
+from diagrams.aws.compute import EC2
+from diagrams.aws.database import RDS
+from diagrams.aws.network import ELB
+
+with Diagram("Grouped Workers", show=False, direction="TB"):
+ ELB("lb") >> [EC2("worker1"),
+ EC2("worker2"),
+ EC2("worker3"),
+ EC2("worker4"),
+ EC2("worker5")] >> RDS("events")
+`,
+ },
+ {
+ title: "Clustered Web Services",
+ code: `from diagrams import Cluster, Diagram
+from diagrams.aws.compute import ECS
+from diagrams.aws.database import ElastiCache, RDS
+from diagrams.aws.network import ELB, Route53
+
+with Diagram("Clustered Web Services", show=False):
+ dns = Route53("dns")
+ lb = ELB("lb")
+
+ with Cluster("Services"):
+ svc_group = [ECS("web1"), ECS("web2"), ECS("web3")]
+
+ with Cluster("DB Cluster"):
+ db_primary = RDS("userdb")
+ db_primary - [RDS("userdb ro")]
+
+ memcached = ElastiCache("memcached")
+
+ dns >> lb >> svc_group
+ svc_group >> db_primary
+ svc_group >> memcached
+`,
+ },
+ {
+ title: "Event Processing (K8s + OnPrem)",
+ code: `from diagrams import Cluster, Diagram
+from diagrams.aws.compute import ECS, EKS, Lambda
+from diagrams.aws.database import Redshift
+from diagrams.aws.integration import SQS
+from diagrams.aws.storage import S3
+
+with Diagram("Event Processing", show=False):
+ source = EKS("k8s source")
+
+ with Cluster("Event Flows"):
+ with Cluster("Event Workers"):
+ workers = [ECS("worker1"), ECS("worker2"), ECS("worker3")]
+
+ queue = SQS("event queue")
+
+ with Cluster("Processing"):
+ handlers = [Lambda("proc1"), Lambda("proc2"), Lambda("proc3")]
+
+ store = S3("events store")
+ dw = Redshift("analytics")
+
+ source >> workers >> queue >> handlers
+ handlers >> store
+ handlers >> dw
+`,
+ },
+];
diff --git a/playground/src/export/exporter.test.ts b/playground/src/export/exporter.test.ts
new file mode 100644
index 00000000..5f6020ae
--- /dev/null
+++ b/playground/src/export/exporter.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from "vitest";
+import { inlineIcons, resolveSize } from "./exporter";
+
+const PNG_BYTES = new Uint8Array([137, 80, 78, 71]);
+
+function fakeFetch(): typeof fetch {
+ return vi.fn(async () => new Response(PNG_BYTES.buffer, { status: 200 })) as unknown as typeof fetch;
+}
+
+describe("inlineIcons", () => {
+ it("replaces icons/ hrefs with data URIs", async () => {
+ const svg = ` `;
+ const result = await inlineIcons(svg, fakeFetch());
+ expect(result).toContain("data:image/png;base64,iVBORw==".slice(0, 30));
+ expect(result).not.toContain("icons/aws");
+ });
+
+ it("fetches each unique icon once", async () => {
+ const fetcher = fakeFetch();
+ const svg = ` `;
+ await inlineIcons(svg, fetcher);
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+
+ it("leaves svg without icons untouched", async () => {
+ const svg = "hi ";
+ expect(await inlineIcons(svg, fakeFetch())).toBe(svg);
+ });
+
+ it("rejects when an icon fetch returns non-ok", async () => {
+ const fetcher = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
+ const svg = ` `;
+ await expect(inlineIcons(svg, fetcher)).rejects.toThrow("HTTP 404");
+ });
+
+ it("uses svg mime for .svg icons", async () => {
+ const svg = ` `;
+ const result = await inlineIcons(svg, fakeFetch());
+ expect(result).toContain("data:image/svg+xml;base64,");
+ });
+});
+
+describe("resolveSize", () => {
+ it("defaults to 2x the natural size when both axes are unset", () => {
+ expect(resolveSize(100, 50)).toEqual({ w: 200, h: 100 });
+ });
+
+ it("treats invalid (non-finite / non-positive) axes as unset, falling back to the 2x default", () => {
+ expect(resolveSize(200, 100, 0, Number.NaN)).toEqual({ w: 400, h: 200 });
+ });
+
+ it("derives height from the natural aspect ratio when only width is given", () => {
+ expect(resolveSize(200, 100, 50)).toEqual({ w: 50, h: 25 });
+ });
+
+ it("derives width from the natural aspect ratio when only height is given", () => {
+ expect(resolveSize(200, 100, undefined, 25)).toEqual({ w: 50, h: 25 });
+ });
+
+ it("uses both axes exactly, allowing aspect distortion", () => {
+ expect(resolveSize(200, 100, 300, 50)).toEqual({ w: 300, h: 50 });
+ });
+
+ it("clamps each axis to the 16px minimum independently after computation", () => {
+ // width=5 -> derived height = round(5 * 100 / 200) = 3, both below 16.
+ expect(resolveSize(200, 100, 5)).toEqual({ w: 16, h: 16 });
+ });
+
+ it("clamps each axis to the 8192px maximum independently after computation", () => {
+ expect(resolveSize(200, 100, 20_000)).toEqual({ w: 8192, h: 8192 });
+ });
+
+ it("falls back to the (clamped) 2x default when the natural size is degenerate, ignoring width/height", () => {
+ expect(resolveSize(0, 100, 300, 150)).toEqual({ w: 16, h: 200 });
+ expect(resolveSize(-10, -20)).toEqual({ w: 16, h: 16 });
+ });
+});
diff --git a/playground/src/export/exporter.ts b/playground/src/export/exporter.ts
new file mode 100644
index 00000000..0f1caa27
--- /dev/null
+++ b/playground/src/export/exporter.ts
@@ -0,0 +1,116 @@
+const ICON_HREF = /(xlink:href|href)="(icons\/[^"]+)"/g;
+
+async function toDataUri(url: string, fetcher: typeof fetch): Promise {
+ const res = await fetcher(url);
+ if (!res.ok) throw new Error(`Failed to fetch icon ${url}: HTTP ${res.status}`);
+ const mime = url.endsWith(".svg") ? "image/svg+xml" : "image/png";
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ let binary = "";
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return `data:${mime};base64,${btoa(binary)}`;
+}
+
+export async function inlineIcons(svg: string, fetcher: typeof fetch = fetch): Promise {
+ const urls = new Set();
+ for (const match of svg.matchAll(ICON_HREF)) urls.add(match[2]);
+ if (!urls.size) return svg;
+ const dataUris = new Map();
+ await Promise.all(
+ [...urls].map(async (url) => dataUris.set(url, await toDataUri(url, fetcher)))
+ );
+ return svg.replace(ICON_HREF, (_full, attr, url) => `${attr}="${dataUris.get(url)}"`);
+}
+
+export interface OutputSize {
+ w: number;
+ h: number;
+}
+
+const MIN_OUTPUT_SIZE = 16;
+const MAX_OUTPUT_SIZE = 8192;
+
+function clampAxis(n: number): number {
+ return Math.min(MAX_OUTPUT_SIZE, Math.max(MIN_OUTPUT_SIZE, Math.round(n)));
+}
+
+// Resolves the raster output size for an export from the optional
+// user-provided WIDTH/HEIGHT fields (both in px):
+// - neither set (or not a positive finite number) -> defaults to 2x the
+// diagram's natural size, matching the export bar's previous default
+// output.
+// - only one axis set -> the other axis is derived from the diagram's
+// natural aspect ratio, so the diagram is never stretched by accident.
+// - both axes set -> used exactly as given; the user explicitly asked for
+// both dimensions, so aspect distortion is allowed.
+// A degenerate natural size (<=0 on either axis, e.g. before an has
+// finished loading) can't produce a meaningful aspect ratio, so it's guarded
+// by always falling back to the default-2x branch, which is itself then
+// clamped, so the result is still a valid, positive canvas size.
+// Every result is rounded and clamped per-axis to [MIN_OUTPUT_SIZE,
+// MAX_OUTPUT_SIZE] so callers can hand it straight to a .
+export function resolveSize(naturalW: number, naturalH: number, width?: number, height?: number): OutputSize {
+ const validWidth = width !== undefined && Number.isFinite(width) && width > 0;
+ const validHeight = height !== undefined && Number.isFinite(height) && height > 0;
+ const naturalOk = naturalW > 0 && naturalH > 0;
+
+ let w: number;
+ let h: number;
+ if (naturalOk && validWidth && validHeight) {
+ w = width as number;
+ h = height as number;
+ } else if (naturalOk && validWidth) {
+ w = width as number;
+ h = ((width as number) * naturalH) / naturalW;
+ } else if (naturalOk && validHeight) {
+ h = height as number;
+ w = ((height as number) * naturalW) / naturalH;
+ } else {
+ w = naturalW * 2;
+ h = naturalH * 2;
+ }
+ return { w: clampAxis(w), h: clampAxis(h) };
+}
+
+export interface PngExportOptions {
+ width?: number;
+ height?: number;
+ mime?: "image/png" | "image/jpeg";
+}
+
+export async function svgToPngBlob(svg: string, options?: PngExportOptions): Promise {
+ const { mime = "image/png", width, height } = options ?? {};
+ const inlined = await inlineIcons(svg);
+ const svgBlob = new Blob([inlined], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(svgBlob);
+ try {
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error("Failed to load SVG for export"));
+ img.src = url;
+ });
+ const { w, h } = resolveSize(img.naturalWidth, img.naturalHeight, width, height);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext("2d")!;
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.scale(w / img.naturalWidth, h / img.naturalHeight);
+ ctx.drawImage(img, 0, 0);
+ return await new Promise((resolve, reject) =>
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), mime)
+ );
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export function download(filename: string, blob: Blob): void {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/playground/src/main.tsx b/playground/src/main.tsx
new file mode 100644
index 00000000..d05d5fd2
--- /dev/null
+++ b/playground/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./app.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/playground/src/renderer/render.test.ts b/playground/src/renderer/render.test.ts
new file mode 100644
index 00000000..8042857f
--- /dev/null
+++ b/playground/src/renderer/render.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { renderDot } from "./render";
+
+describe("renderDot", () => {
+ it("renders plain dot to svg", async () => {
+ const svg = await renderDot("digraph { a -> b }");
+ expect(svg).toContain(" {
+ const dot = `digraph {
+ n [label="web" height="1.9" image="/site-packages/resources/aws/compute/ec2.png" shape=none fixedsize=true width="1.4"]
+ }`;
+ const svg = await renderDot(dot);
+ expect(svg).toContain(`icons/aws/compute/ec2.png`);
+ }, 30_000);
+
+ it("strips script elements injected via labels", async () => {
+ const svg = await renderDot(`digraph { a [label="<x>"] }`);
+ expect(svg).not.toContain("