diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 02da759..6ae8e97 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -35,25 +35,27 @@ jobs: # abgeleitet (Standard wäre ein flacher Klon mit nur einem Commit). fetch-depth: 0 - # Platzhalter für spätere Vitest-Läufe (Tests existieren noch nicht, - # siehe docs/TASKS.md, Phase 1). Bei Aktivierung: Node einrichten und - # Tests ausführen — schlägt der Testlauf fehl, wird nicht deployt. - # - name: Node einrichten - # uses: actions/setup-node@v4 - # with: - # node-version: 20 - # cache: npm - # - name: Abhängigkeiten installieren - # run: npm ci - # - name: Tests - # run: npm test + # Node einrichten und den Vite-Build fahren (D19): der Editor liegt jetzt + # als ES-Module unter frontend/src/, Vite bündelt sie zu EINER + # self-contained frontend/dist/index.html (JS+CSS+Favicon inline). Tests + # laufen zuerst — schlägt Vitest fehl, wird nicht deployt. + - name: Node einrichten + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Abhängigkeiten installieren + run: npm ci --prefix frontend + - name: Tests + run: npm test --prefix frontend + - name: Build (Vite) + run: npm run build --prefix frontend - # Site-Ordner zusammenstellen: index.html an die Wurzel-URL, dazu die - # vom Editor referenzierten Dateien (Favicon aus docs/brand/, LICENSE für - # den MIT-Link im Footer). Die Editor-Quelle bleibt unverändert — die - # ../-Pfade werden nur auf der Kopie im Site-Ordner geradegezogen, weil - # sie von der Wurzel-URL aus sonst über die Site hinauszeigen würden. - # Zusätzlich werden im Footer gesetzt: + # Site-Ordner zusammenstellen: die gebaute dist/index.html an die + # Wurzel-URL, dazu LICENSE für den MIT-Link im Footer. Das Favicon ist im + # Build bereits als data:-URI inline (kein docs/brand-Kopieren mehr nötig). + # Im Footer werden gesetzt: # - die Versionsnummer: Major.Minor aus der VERSION-Datei (per # Bump-Commit gepflegt), die Micro-Stelle aus der Anzahl der Commits # seit dem letzten VERSION-Bump — steigt also je Commit und beginnt @@ -61,6 +63,9 @@ jobs: # - der Link der Versionsnummer: zeigt exakt auf den deployten Commit # (…/commit/). „Werkbaum" selbst verlinkt weiter die Repo- # Startseite. Braucht die volle Historie (fetch-depth: 0). + # Der ../LICENSE-Link ist eine Laufzeit-Verknüpfung (kein Vite-Asset) und + # zeigt von der Wurzel-URL aus sonst über die Site hinaus — daher hier auf + # der Kopie geradegezogen. - name: Site zusammenstellen run: | MAJORMINOR="$(tr -d '[:space:]' < VERSION)" @@ -70,13 +75,11 @@ jobs: BUILD_VERSION="${MAJORMINOR}.${MICRO}" COMMIT_URL="https://github.com/mhoennig/werkbaum/commit/$(git rev-parse HEAD)" echo "Footer-Version: ${BUILD_VERSION} -> ${COMMIT_URL}" - mkdir -p site/docs/brand - sed -e 's#\.\./docs/brand/#docs/brand/#g' \ - -e 's#\.\./LICENSE#LICENSE#g' \ + mkdir -p site + sed -e 's#\.\./LICENSE#LICENSE#g' \ -e "s#\(]*>\)[0-9.]\+#\1${BUILD_VERSION}#" \ - frontend/index.html > site/index.html - cp docs/brand/* site/docs/brand/ + frontend/dist/index.html > site/index.html cp LICENSE site/LICENSE - name: Pages-Konfiguration diff --git a/README.de.md b/README.de.md index 2fa18ff..12afaba 100644 --- a/README.de.md +++ b/README.de.md @@ -34,10 +34,27 @@ der sie als Diagramm rendert. ![Werkbaum-Editor: Live-Diagramm oben, Textnotation darunter, mit Statusfarben, T-Shirt-Größen, Tags und Export-Schaltflächen](docs/screenshot.png) -Den [gehosteten Editor](https://mhoennig.github.io/werkbaum/) öffnen — oder -`frontend/index.html` im Browser — links Text bearbeiten, rechts entsteht das -Diagramm live. Toggles: transponierte (schmale) Darstellung, verworfene -Elemente einblenden. +Den [gehosteten Editor](https://mhoennig.github.io/werkbaum/) öffnen — links +Text bearbeiten, rechts entsteht das Diagramm live. Toggles: transponierte +(schmale) Darstellung, verworfene Elemente einblenden. + +### Lokal ausführen + +Die Editor-Quelle liegt jetzt als ES-Module unter `frontend/src/`, gebündelt mit +[Vite](https://vitejs.dev/) (siehe `docs/DECISIONS.md` D19). Da Browser +ES-Modul-Importe über `file://` blocken, funktioniert das direkte Öffnen von +`frontend/index.html` nicht mehr — stattdessen: + +```bash +cd frontend +npm install # einmalig +npm run dev # Dev-Server unter http://localhost:8137 +npm test # Vitest-Unit-Tests +npm run build # -> frontend/dist/index.html (eine self-contained Datei) +``` + +Die gebaute `dist/index.html` inlint JS, CSS und Favicon — **diese** Datei öffnet +also standalone per `file://` und ist zugleich das, was deployt wird. ## Projektdokumente @@ -56,12 +73,13 @@ Der Editor wird per GitHub Actions als statische Seite auf **GitHub Pages** veröffentlicht (Workflow: `.github/workflows/pages.yml`). Ausgelöst bei jedem Push auf `main` sowie manuell (`workflow_dispatch`). -Der Workflow stellt einen Site-Ordner zusammen: `frontend/index.html` liegt als -`index.html` an der Wurzel-URL, dazu die vom Editor referenzierten Dateien -(`docs/brand/` fürs Favicon, `LICENSE` für den MIT-Link im Footer). Die -`../`-Pfade der Editor-Quelle werden dabei nur auf der Kopie geradegezogen — die -Quelldatei bleibt unverändert. `backend/` und die übrigen `docs/` werden nicht -veröffentlicht. +Der Workflow richtet Node ein, führt `npm ci`, `npm test` (Vitest) und +`npm run build` (Vite) aus und veröffentlicht die gebündelte +`frontend/dist/index.html` als `index.html` an der Wurzel-URL, dazu `LICENSE` +für den MIT-Link im Footer. Das Favicon ist im Build bereits inline, es muss also +nichts weiter kopiert werden; nur der Laufzeit-Link `../LICENSE` wird auf der +Kopie geradegezogen. Ein fehlschlagender Test blockiert das Deployment. +`backend/` und die übrigen `docs/` werden nicht veröffentlicht. Beim Zusammenstellen setzt der Workflow zudem die Versionsnummer im Footer: **Major.Minor** stammt aus der Datei `VERSION` (per bewusstem „Bump-Commit" diff --git a/README.md b/README.md index 632d3f9..269f35c 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,27 @@ and/or decomposition — plus a live editor that renders it as a diagram. ![Werkbaum editor: live diagram on top, text notation below, with status colours, T-shirt sizes, tags and export buttons](docs/screenshot.png) -Open the [hosted editor](https://mhoennig.github.io/werkbaum/) — or -`frontend/index.html` in the browser — edit text on the left, the diagram is -built live on the right. Toggles: transposed (narrow) layout, show discarded -elements. +Open the [hosted editor](https://mhoennig.github.io/werkbaum/) — edit text on +the left, the diagram is built live on the right. Toggles: transposed (narrow) +layout, show discarded elements. + +### Running it locally + +The editor source now lives as ES modules under `frontend/src/`, bundled by +[Vite](https://vitejs.dev/) (see `docs/DECISIONS.md` D19). Because browsers block +ES-module imports over `file://`, opening `frontend/index.html` directly no +longer works — use one of: + +```bash +cd frontend +npm install # once +npm run dev # dev server at http://localhost:8137 +npm test # Vitest unit tests +npm run build # -> frontend/dist/index.html (single self-contained file) +``` + +The built `dist/index.html` inlines all JS, CSS and the favicon, so **that** file +does open standalone via `file://` and is what gets deployed. ## Project documents @@ -59,11 +76,12 @@ The editor is published as a static page on **GitHub Pages** via GitHub Actions (workflow: `.github/workflows/pages.yml`). Triggered on every push to `main` and manually (`workflow_dispatch`). -The workflow assembles a site folder: `frontend/index.html` is placed as -`index.html` at the root URL, along with the files the editor references -(`docs/brand/` for the favicon, `LICENSE` for the MIT link in the footer). The -`../` paths in the editor source are straightened only on the copy — the source -file stays unchanged. `backend/` and the remaining `docs/` are not published. +The workflow sets up Node, runs `npm ci`, `npm test` (Vitest) and `npm run build` +(Vite), then publishes the bundled `frontend/dist/index.html` as `index.html` at +the root URL, plus `LICENSE` for the MIT link in the footer. The favicon is +already inlined into the build, so nothing else needs copying; only the runtime +`../LICENSE` link is straightened on the copy. A failing test blocks the deploy. +`backend/` and the remaining `docs/` are not published. While assembling the site, the workflow also stamps the version into the footer: **major.minor** comes from the `VERSION` file (bumped by an explicit "bump diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 393c7e3..7d707f8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -214,3 +214,40 @@ Knoten von seiner Linie von oben abgetrennt. Fix: `ul.or>li.has-and` bekommt vertikal **symmetrisches** Padding (Mitte bleibt bei 50 %), der Abzweig (`::before`) und die Rail-Endkante (`:last-child::after`) werden auf 50 % gesetzt — analog zu den bereits zentrierten all-of-Zwischenknoten. + +## D19 — Modularisierung mit Vite-Bündelung zu einer self-contained Datei +Das Nahziel „tragfähige Codebasis" (ROADMAP) verlangt, den Single-HTML- +Prototyp in headless nutzbare Module (`parser`, `model`, `render`, `app`) zu +zerlegen und gegen SPEC zu testen. Die Zwickmühle: **modulare Einzeldateien**, +**`file://`-Tauglichkeit** (D16) und **kein Build** (D11) sind nicht gleichzeitig +erfüllbar — ES-`import` über `file://` blockt der Browser (CORS). + +**Entscheidung: Vite als Bündler/Testrunner.** `frontend/src/*.js` sind echte +ES-Module und die Quelle der Wahrheit; `frontend/index.html` lädt im Dev-Server +per ` - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f42c8e3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1897 @@ +{ + "name": "werkbaum-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "werkbaum-frontend", + "version": "0.0.0", + "devDependencies": { + "jsdom": "^29.1.1", + "vite": "^8.1.5", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "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/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.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/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "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/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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "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/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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "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": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/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" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..84a74c3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "werkbaum-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Werkbaum WBS-Editor (Vanilla HTML/CSS/JS, mit Vite gebündelt zu einer einzelnen self-contained index.html).", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "jsdom": "^29.1.1", + "vite": "^8.1.5", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.10" + } +} diff --git a/frontend/src/app.js b/frontend/src/app.js new file mode 100644 index 0000000..c8b5982 --- /dev/null +++ b/frontend/src/app.js @@ -0,0 +1,1226 @@ +import './style.css'; + +const INITIAL = `%% Project structure – Sprint 14 +[~] Website relaunch (XL) https://wiki.example.com/relaunch + - [x] Concept (M) + - [x] Audience analysis (S) + - [x] Sitemap (XS) + - [~] Implementation (XL) + - [~] Frontend (M) https://git.example.com/frontend @anna + | [ ] PWA (S) + | [ ] Web+Nativ + - [/] Web (S) + - [ ] Android (M) + - [ ] iOS (M) + - [ ] Backend (L) @ben @carla + - [ ] CMS integration (M) + | [ ] WordPress + | [?] Headless CMS + | [-] Custom build %% too much effort + - [?] Hosting (M) + | Cooperative Community Cloud https://hostsharing.net + | On-premise`; + +const SIZE_RANK = {XS:0, S:1, M:2, L:3, XL:4, XXL:5}; + +const STATUS_BY_CODE = { + '?': {key:'idee', name:'Idee'}, + ' ': {key:'geplant', name:'geplant'}, + '~': {key:'arbeit', name:'in Arbeit'}, + '/': {key:'durchstich', name:'Durchstich – funktionsbereit, Feinarbeiten offen'}, + 'x': {key:'fertig', name:'fertig'}, + '^': {key:'prod', name:'in Produktion'}, + '-': {key:'verworfen', name:'verworfen'} +}; + +const src = document.getElementById('src'); +const out = document.getElementById('out'); +const warnBox = document.getElementById('warn'); + +function esc(s){ + return s.replace(/&/g,'&').replace(//g,'>'); +} + +/* ---------- Parser ---------- */ +function parse(text){ + const virtualRoot = {label:'', type:'and', children:[]}; + const stack = [{node:virtualRoot, width:-1}]; + const warnings = []; + + text.split('\n').forEach((raw, i) => { + raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */ + if(!raw.trim()) return; + const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/); + const width = m[1].replace(/\t/g,' ').length; + const type = m[2] === '|' ? 'or' : 'and'; + const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null; + + let rest = m[4], url = null, size = null; + const tags = []; + rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; }); + rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; }); + rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; }); + const label = rest.replace(/\s+/g, ' ').trim(); + if(!label) return; + + while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); + const parent = stack[stack.length-1].node; + + const node = {label, type, status, url, size, tags, children:[], line:i+1}; + parent.children.push(node); + stack.push({node, width}); + }); + + return {roots: virtualRoot.children, warnings}; +} + +/* ---------- Renderer ---------- */ +function gateOf(children){ + return children.length && children[0].type === 'or' ? 'or' : 'and'; +} + +function needsBreakdown(n){ + if(n.status && n.status.key === 'verworfen') return false; + return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length; +} + +function visibleChildren(n){ + if(discardedShown()) return n.children; + return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); +} + +/* ---------- Günstigster Pfad ---------- + Markiert die Knoten, die für die günstigste Realisierung nötig sind: + all-of ⇒ alle Kinder nötig; any-of ⇒ nur die günstigste Alternative. + „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe + Kinder; + bei any-of das Minimum). Verworfene zählen nie mit — unabhängig vom + Einblenden-Toggle. Gleichstand ⇒ erste Alternative. Fehlende Größe = M. */ +let cheapPathOn = true; +const cheapSet = new Set(); +function pathChildren(n){ + return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); +} +/* fehlende Größe wird als M interpretiert */ +function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; } +function cheapestCost(n){ + const kids = pathChildren(n); + let c = ownCost(n); + if(kids.length){ + if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost)); + else c += kids.reduce((s, k) => s + cheapestCost(k), 0); + } + return c; +} +function markCheapest(n){ + cheapSet.add(n); + const kids = pathChildren(n); + if(!kids.length) return; + if(gateOf(kids) === 'or'){ + let best = null, bc = Infinity; + for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } + if(best) markCheapest(best); + } else { + for(const k of kids) markCheapest(k); + } +} +function cheapCls(n){ + if(!(cheapPathOn && cheapSet.has(n))) return ''; + /* Endknoten des Pfads: kein Kind liegt (mehr) auf dem günstigen Pfad */ + const leaf = !pathChildren(n).some(k => cheapSet.has(k)); + return leaf ? 'cheap cheap-leaf' : 'cheap'; +} + +function nodeHtml(n, extra){ + const need = needsBreakdown(n); + const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : ''] + .filter(Boolean).join(' '); + const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : ''; + const tagsHtml = n.tags && n.tags.length + ? `${n.tags.map(tag => `${esc(tag)}`).join('')}` + : ''; + const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"'); + const sizeBadge = n.size + ? `${n.size}` + : (cheapPathOn ? `M` : ''); + const inner = esc(n.label) + + (n.url ? '' : '') + + sizeBadge + + tagsHtml; + const html = n.url + ? `${inner}` + : `
${inner}
`; + const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"'); + const ghost = `
${esc(t('ghost'))}
`; + return html + (need ? ghost : ''); +} + +function renderChildren(node, warnings){ + const kids = visibleChildren(node); + if(!kids.length) return ''; + const types = new Set(kids.map(k => k.type)); + if(types.size > 1){ + warnings.push(t('mixedWarn', {line: kids[0].line, label: esc(node.label)})); + } + const gate = gateOf(kids); + const items = kids.map(k => { + const vk = visibleChildren(k); + const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + return `` + + nodeHtml(k, cheapCls(k)) + + renderChildren(k, warnings) + + ``; + }).join(''); + return ``; +} + +function render(){ + let {roots} = parse(src.value); + const warnings = []; + if(!discardedShown()){ + roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); + } + + if(!roots.length){ + out.innerHTML = `
${esc(t('empty'))}
`; + warnBox.innerHTML = ''; + return; + } + + cheapSet.clear(); + if(cheapPathOn) roots.forEach(markCheapest); + out.classList.toggle('cheap-on', cheapPathOn); + + out.innerHTML = roots.map(root => { + const vk = visibleChildren(root); + const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + return `` + + nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) + + renderChildren(root, warnings) + + ``; + }).join(''); + + warnBox.innerHTML = warnings.map(w => `
⚠ ${w}
`).join(''); + drawCheapPath(); +} + +/* ---------- Günstigster-Pfad-Linie ---------- + Eine gestrichelte, geschwungene Petrol-Linie fädelt durch die Endknoten + (Blätter) des günstigen Pfads. Das Overlay-SVG liegt in #out und erbt damit + dessen CSS-`zoom`; die Punkte werden in unskalierte #out-Koordinaten + umgerechnet (getBoundingClientRect / zoom). Neu gezeichnet nach jedem + render() und nach Moduswechsel (applyLayout ruft nicht render). */ +const SVGNS = 'http://www.w3.org/2000/svg'; +function catmullRom(p){ + if(p.length < 2) return ''; + if(p.length === 2) return `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)} L${p[1].x.toFixed(1)},${p[1].y.toFixed(1)}`; + let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`; + for(let i = 0; i < p.length - 1; i++){ + const p0 = p[i-1] || p[i], p1 = p[i], p2 = p[i+1], p3 = p[i+2] || p2; + const c1x = p1.x + (p2.x - p0.x)/6, c1y = p1.y + (p2.y - p0.y)/6; + const c2x = p2.x - (p3.x - p1.x)/6, c2y = p2.y - (p3.y - p1.y)/6; + d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; + } + return d; +} +function svgEl(name, attrs){ + const e = document.createElementNS(SVGNS, name); + for(const k in attrs) e.setAttribute(k, attrs[k]); + return e; +} +function overlaySvg(cls, w, h){ + const svg = svgEl('svg', {class:'cheap-overlay ' + cls, width:w, height:h, + viewBox:`0 0 ${w.toFixed(1)} ${h.toFixed(1)}`}); + return svg; +} +function drawCheapPath(){ + out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove()); + if(!cheapPathOn) return; + const leaves = [...out.querySelectorAll('.node.cheap-leaf')]; /* Dokument-Reihenfolge = Lese-Reihenfolge */ + if(leaves.length < 2) return; + const outRect = out.getBoundingClientRect(); + const z = zoom || 1; + if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */ + const pts = leaves.map(el => { + const r = el.getBoundingClientRect(); + return {x:(r.left + r.width/2 - outRect.left)/z, y:(r.top + r.height/2 - outRect.top)/z}; + }); + const w = outRect.width/z, h = outRect.height/z; + const d = catmullRom(pts); + + /* kräftige Linie HINTER die Knoten (als erstes Kind → hinterste Paint-Ebene) */ + const back = overlaySvg('cheap-back', w, h); + back.appendChild(svgEl('path', {class:'cheap-path', d})); + out.insertBefore(back, out.firstChild); + + /* davor: abgetönte Kopie (deutet den Verlauf über Knoten an) + Stationspunkte */ + const front = overlaySvg('cheap-front', w, h); + front.appendChild(svgEl('path', {class:'cheap-path faint', d})); + pts.forEach(p => front.appendChild( + svgEl('circle', {class:'cheap-dot', cx:p.x.toFixed(1), cy:p.y.toFixed(1), r:10}))); + out.appendChild(front); +} + +/* ---------- Diagramm als Grafik (SVG → PNG) ---------- */ +/* Das gerenderte Diagramm wird aus der Live-Geometrie in ein eigenständiges + SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet und als + PNG in die Zwischenablage gelegt. Knotenfarben, Größen-Badges, Tags und + der Geister-Knoten werden übernommen; Verbindungslinien werden je Gate + (und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen + so garantiert die Knoten — unabhängig vom Darstellungsmodus. */ +function diagramToSvg(){ + const treeRect = out.getBoundingClientRect(); + const PAD = 24; + const W = Math.ceil(treeRect.width) + PAD*2; + const H = Math.ceil(treeRect.height) + PAD*2; + const ox = -treeRect.left + PAD, oy = -treeRect.top + PAD; + const R = el => { const r = el.getBoundingClientRect(); + return {x:r.left+ox, y:r.top+oy, w:r.width, h:r.height, + cx:r.left+ox+r.width/2, cy:r.top+oy+r.height/2, r:r.right+ox, b:r.bottom+oy}; }; + const parts = [``]; + const nodes = [...out.querySelectorAll('.node')]; + const cheapPts = cheapPathOn + ? [...out.querySelectorAll('.node.cheap-leaf')].map(el => { const b = R(el); return {x:b.cx, y:b.cy}; }) + : []; + const cheapLine = op => + ``; + + /* 1) Verbindungslinien (hinter den Knoten) */ + const seg = (x1,y1,x2,y2,stroke,dash) => + ``; + nodes.forEach(parentEl => { + const li = parentEl.closest('li'); + const childUl = li && [...li.children].find(c => c.tagName === 'UL'); + if(!childUl) return; + const gate = childUl.classList.contains('or') ? 'or' : 'and'; + const stroke = gate === 'or' ? '#6B7A8C' : '#41556E'; + const dash = gate === 'or'; + const kids = [...childUl.children] + .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean).map(R); + if(!kids.length) return; + const p = R(parentEl); + const avgdx = kids.reduce((s,k)=>s+(k.cx-p.cx),0)/kids.length; + const avgdy = kids.reduce((s,k)=>s+(k.cy-p.cy),0)/kids.length; + if(Math.abs(avgdx) >= Math.abs(avgdy)){ /* links→rechts */ + const toRight = avgdx >= 0; + const px = toRight ? p.r : p.x; + const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14; + const ys = kids.map(k=>k.cy).concat(p.cy); + parts.push(seg(px, p.cy, busX, p.cy, stroke, dash)); + parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); + kids.forEach(k => parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash))); + } else { /* oben→unten */ + const toDown = avgdy >= 0; + const py = toDown ? p.b : p.y; + const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14; + const xs = kids.map(k=>k.cx).concat(p.cx); + parts.push(seg(p.cx, py, p.cx, busY, stroke, dash)); + parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); + kids.forEach(k => parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash))); + } + }); + + /* 1b) Günstigster-Pfad: kräftige Linie hinter den Knoten */ + if(cheapPts.length >= 2) parts.push(cheapLine('0.9')); + + /* 2) Badge/Pille (Größe, Tags) */ + const drawBadge = (el, fill, textColor, strokeColor) => { + const b = R(el); + parts.push(``); + parts.push(`${esc(el.textContent.trim())}`); + }; + + /* 3) Knoten */ + nodes.forEach(node => { + const b = R(node), cs = getComputedStyle(node); + const dashed = cs.borderTopStyle === 'dashed'; + parts.push(``); + const clone = node.cloneNode(true); + clone.querySelectorAll('.size,.tags,.ext').forEach(e => e.remove()); + const label = clone.textContent.replace(/\s+/g,' ').trim(); + const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; + parts.push(`${esc(label)}`); + const sizeEl = node.querySelector('.size'); + if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff'); + node.querySelectorAll('.tag').forEach(tg => { + const t = getComputedStyle(tg); + drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); + }); + }); + + /* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */ + if(cheapPts.length >= 2){ + parts.push(cheapLine('0.2')); + cheapPts.forEach(p => parts.push( + ``)); + } + + /* 4) Geister-Knoten „Untergliederung fehlt“ */ + out.querySelectorAll('.ghost-node').forEach(g => { + const b = R(g); + parts.push(seg(b.cx, b.y-14, b.cx, b.y, '#B45309', true)); + parts.push(``); + parts.push(`${esc(g.textContent.trim())}`); + }); + + const svg = `${parts.join('')}`; + return {svg, W, H}; +} +function svgToPng(svg, W, H, scale){ + return new Promise(resolve => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = Math.round(W*scale); c.height = Math.round(H*scale); + const ctx = c.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + c.toBlob(b => resolve({blob:b, dataUrl:c.toDataURL('image/png')}), 'image/png'); + }; + img.onerror = () => resolve(null); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); +} +/* Kopiert das Diagramm als PNG-Bild. Zusätzlich wird eine text/html-Variante + mit eingebettetem PNG mitgegeben — Office-Programme wie LibreOffice Writer + bevorzugen den HTML-Flavor und betten das Bild dann korrekt ein. */ +async function copyDiagramImage(){ + const {svg, W, H} = diagramToSvg(); + const png = await svgToPng(svg, W, H, 2); + if(png && png.blob && navigator.clipboard && window.ClipboardItem){ + const html = new Blob( + [`Werkbaum-Diagramm`], + {type:'text/html'}); + try{ + await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob, 'text/html':html})]); + return; + }catch(_){ + try{ await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob})]); return; }catch(_){} + } + } + await writeClipboard(svg); /* Fallback: SVG-Quelltext (ebenfalls Grafik) */ +} +/* Als Datei speichern (SVG, Vektor). Verlässlicher Weg z. B. für LibreOffice + Writer: Einfügen → Bild → die Datei; das Bild-Clipboard aus dem Browser + erkennt LibreOffice nicht zuverlässig. */ +function saveBlob(blob, filename){ + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +function downloadDiagramSvg(){ + const {svg} = diagramToSvg(); + saveBlob(new Blob([`\n` + svg], {type:'image/svg+xml'}), + 'werkbaum-diagramm.svg'); +} +async function downloadDiagramPng(){ + const {svg, W, H} = diagramToSvg(); + const png = await svgToPng(svg, W, H, 2); + if(png && png.blob) saveBlob(png.blob, 'werkbaum-diagramm.png'); +} + +/* Tab-Taste rückt ein statt den Fokus zu wechseln */ +src.addEventListener('keydown', e => { + if(e.key === 'Tab'){ + e.preventDefault(); + const {selectionStart:s, selectionEnd:eEnd, value} = src; + src.value = value.slice(0, s) + ' ' + value.slice(eEnd); + src.selectionStart = src.selectionEnd = s + 2; + render(); + saveSrc(); + } +}); + +src.addEventListener('input', render); +src.addEventListener('input', saveSrc); + +const app = document.getElementById('app'); +function applyLayout(mode){ + out.classList.toggle('vertical', mode === 'vertikal'); + out.classList.toggle('kompakt', mode === 'kompakt'); + app.classList.toggle('side', mode !== 'horizontal'); + if(!isMobile()) applySplit(); /* Desktop: Preset neu setzen. Mobil: freie --drow-Aufteilung behalten */ + drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */ +} +document.querySelectorAll('input[name="layout"]').forEach(radio => { + radio.addEventListener('change', () => { if(radio.checked) applyLayout(radio.value); }); +}); + +/* ---------- Min/Normal/Max je Bereich (Fenster-Buttons) ---------- */ +/* Zustand 'a' = Diagramm minimiert (Editor maximiert), + 'b' = Editor minimiert (Diagramm maximiert), 'normal' = beide sichtbar. + Minimieren schrumpft ein Panel auf seine Titelzeile; Maximieren des einen + entspricht dem Minimieren des anderen. */ +let splitState = 'normal'; +const editorPanel = document.querySelector('.panel.editor'); +const diagramPanel = document.querySelector('.panel.right'); +function clearCollapse(){ + app.classList.remove('collapse-editor','collapse-diagram'); + editorPanel.classList.remove('collapsed'); + diagramPanel.classList.remove('collapsed'); + document.querySelectorAll('.winbtn').forEach(b => b.classList.remove('active')); +} +function applySplit(){ + /* Preset-/Drag-Größen bei Preset-Wechsel zurücksetzen */ + app.style.removeProperty('--col'); + app.style.removeProperty('--drow'); + app.classList.toggle('collapse-diagram', splitState==='a'); + app.classList.toggle('collapse-editor', splitState==='b'); + diagramPanel.classList.toggle('collapsed', splitState==='a'); + editorPanel.classList.toggle('collapsed', splitState==='b'); + document.querySelectorAll('.winbtn').forEach(b => b.classList.toggle('active', b.dataset.state===splitState)); + saveUI(); +} +/* Setzt einen Minimier-Zustand während des Ziehens nur bei Änderung. */ +function snapTo(state){ if(splitState!==state){ splitState = state; applySplit(); } } + +/* ---------- Mobil: freie Aufteilung (kontinuierlich, kein Snap/Collapse) ---------- + Beide Titelzeilen bleiben stehen: die Grid-Zeilen-Minima --pmin-d/--pmin-e + entsprechen den (gemessenen) Kopfzeilenhöhen. --drow steuert die Diagramm- + Höhe; das Tippen auf eine Titelzeile setzt sie auf ein Extrem. */ +function headPx(sel){ return Math.ceil(document.querySelector(sel).getBoundingClientRect().height); } +function syncPanelMins(){ + app.style.setProperty('--pmin-d', headPx('.panel.right .panel-head') + 'px'); + app.style.setProperty('--pmin-e', headPx('.panel.editor .panel-head') + 'px'); +} +function cssPx(name, fallback){ return parseFloat(getComputedStyle(app).getPropertyValue(name)) || fallback; } +function mobileMinDrow(){ return cssPx('--pmin-d', 49); } +function mobileMaxDrow(){ return app.getBoundingClientRect().height - 14 - cssPx('--pmin-e', 44); } +function setMobileDrow(px, save){ + const v = Math.max(mobileMinDrow(), Math.min(px, mobileMaxDrow())); + splitState = 'custom'; + app.style.setProperty('--drow', v + 'px'); + if(save) saveUI(); +} +document.querySelectorAll('.winbtn').forEach(b => { + b.addEventListener('click', e => { + /* Fenster-Buttons sind maßgeblich: nicht zum Titelzeilen-Restore durchreichen */ + e.stopPropagation(); + splitState = b.dataset.state; applySplit(); + }); +}); +/* Klick auf die Titelzeile eines minimierten Panels stellt es wieder her. */ +[editorPanel, diagramPanel].forEach(panel => { + panel.querySelector('.panel-head').addEventListener('click', e => { + if(e.target.closest('button, label, input')) return; /* Bedienelemente behalten ihre Funktion */ + if(isMobile()){ + /* Tippen auf eine Titelzeile klappt DIESES Panel ganz aus (das andere + schrumpft auf seine Titelzeile) — funktioniert in jeder Aufteilung. */ + clearCollapse(); + setMobileDrow(panel === diagramPanel ? mobileMaxDrow() : 0, true); + return; + } + /* Desktop: nur ein minimiertes Panel per Titelzeile wiederherstellen. */ + if(!panel.classList.contains('collapsed')) return; + splitState = 'normal'; + applySplit(); + }); +}); + +/* ---------- Splitter: Bereiche per Drag verteilen ---------- */ +const gutter = document.getElementById('gutter'); +const diagramEl = document.querySelector('.diagram'); +let dragging = false; +gutter.addEventListener('pointerdown', e => { + dragging = true; + gutter.classList.add('dragging'); + gutter.setPointerCapture(e.pointerId); + document.body.style.userSelect = 'none'; + /* Freies Ziehen hebt Preset und Minimierung auf */ + splitState = 'custom'; + clearCollapse(); + e.preventDefault(); +}); +/* Näher als SNAP an einem Rand: in den minimierten Zustand einrasten, + sonst frei skalieren. So sind beide Extreme per Splitter erreichbar. */ +const SNAP = 72; +gutter.addEventListener('pointermove', e => { + if(!dragging) return; + const rect = app.getBoundingClientRect(); + if(isMobile()){ + /* Frei ziehen; die Grid-Minima (--pmin) halten beide Titelzeilen sichtbar. + Kein Snap/Collapse — so bleibt der Splitter jederzeit beweglich. */ + setMobileDrow(e.clientY - rect.top, false); + return; + } + if(app.classList.contains('side')){ + const w = e.clientX - rect.left, maxw = rect.width - 14; + if(w <= SNAP) snapTo('b'); /* Editor auf Titelzeile */ + else if(w >= maxw - SNAP) snapTo('a'); /* Diagramm auf Titelzeile */ + else { + if(splitState!=='custom'){ splitState='custom'; clearCollapse(); } + app.style.setProperty('--col', w + 'px'); + } + } else { + const h = e.clientY - rect.top, maxh = rect.height - 14; + if(h <= SNAP) snapTo('a'); /* Diagramm (oben) auf Titelzeile */ + else if(h >= maxh - SNAP) snapTo('b'); /* Editor (unten) auf Titelzeile */ + else { + if(splitState!=='custom'){ splitState='custom'; clearCollapse(); } + app.style.setProperty('--drow', h + 'px'); + } + } +}); +function endDrag(e){ + if(!dragging) return; + dragging = false; + gutter.classList.remove('dragging'); + document.body.style.userSelect = ''; + try{ gutter.releasePointerCapture(e.pointerId); }catch(_){} + saveUI(); /* freie Drag-Groesse (--col/--drow) sichern */ +} +gutter.addEventListener('pointerup', endDrag); +gutter.addEventListener('pointercancel', endDrag); +/* Doppelklick auf den Splitter stellt die normale Aufteilung wieder her + (auf Mobil: Diagramm maximiert). */ +gutter.addEventListener('dblclick', () => { + if(isMobile()){ clearCollapse(); setMobileDrow(mobileMaxDrow(), true); return; } + splitState = 'normal'; applySplit(); +}); + +/* ---------- Zoom für das Diagramm ---------- */ +/* CSS-`zoom` skaliert die Layout-Box, dadurch greifen die Scrollbalken + des Diagramm-Containers korrekt (anders als transform: scale). */ +const ZMIN = 0.3, ZMAX = 3, ZSTEP = 0.1; +let zoom = 1; +function applyZoom(){ + zoom = Math.min(ZMAX, Math.max(ZMIN, Math.round(zoom * 100) / 100)); + out.style.zoom = zoom; + document.getElementById('zoomReset').textContent = Math.round(zoom * 100) + ' %'; + saveUI(); +} +document.getElementById('zoomIn').addEventListener('click', () => { zoom += ZSTEP; applyZoom(); }); +document.getElementById('zoomOut').addEventListener('click', () => { zoom -= ZSTEP; applyZoom(); }); +document.getElementById('zoomReset').addEventListener('click', () => { zoom = 1; applyZoom(); }); +diagramEl.addEventListener('wheel', e => { + if(!(e.ctrlKey || e.metaKey)) return; /* Strg/Cmd + Mausrad zoomt */ + e.preventDefault(); + zoom += (e.deltaY < 0 ? ZSTEP : -ZSTEP); + applyZoom(); +}, {passive:false}); + +const showc = document.getElementById('showc'); +function discardedShown(){ return showc.getAttribute('aria-pressed') === 'true'; } +function setDiscarded(on){ showc.setAttribute('aria-pressed', on ? 'true' : 'false'); } +showc.addEventListener('click', () => { setDiscarded(!discardedShown()); render(); saveUI(); }); + +/* Günstigster-Pfad-Hervorhebung an/aus */ +const cheapBtn = document.getElementById('cheapBtn'); +cheapBtn.addEventListener('click', () => { + cheapPathOn = !cheapPathOn; + cheapBtn.setAttribute('aria-pressed', cheapPathOn ? 'true' : 'false'); + render(); + saveUI(); +}); + +/* ---------- In die Zwischenablage kopieren ---------- */ +async function writeClipboard(text){ + try{ await navigator.clipboard.writeText(text); return; }catch(_){} + const ta = document.createElement('textarea'); /* Fallback ohne Clipboard-API */ + ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try{ document.execCommand('copy'); }catch(_){} + document.body.removeChild(ta); +} +function flashCopied(btn, restoreTitleKey){ + btn.classList.add('done'); + btn.title = t('copyDone'); + btn.setAttribute('aria-label', t('copyDone')); + setTimeout(() => { + btn.classList.remove('done'); + btn.title = t(restoreTitleKey); + btn.setAttribute('aria-label', t(restoreTitleKey)); + }, 1500); +} +document.getElementById('copy').addEventListener('click', async () => { + await writeClipboard(src.value); + flashCopied(document.getElementById('copy'), 'copyTooltip'); +}); +document.getElementById('copyDiagram').addEventListener('click', async () => { + await copyDiagramImage(); + flashCopied(document.getElementById('copyDiagram'), 'copyDiagramTooltip'); +}); +function flashBtn(btn){ /* kurzer Petrol-Blitz als Rückmeldung, Tooltip bleibt */ + btn.classList.add('done'); + setTimeout(() => btn.classList.remove('done'), 1500); +} +document.getElementById('dlDiagram').addEventListener('click', () => { + downloadDiagramSvg(); + flashBtn(document.getElementById('dlDiagram')); + closeDlMenu(); +}); +document.getElementById('dlDiagramPng').addEventListener('click', async () => { + closeDlMenu(); + await downloadDiagramPng(); + flashBtn(document.getElementById('dlDiagramPng')); +}); + +/* Download-Menü (nur Mobil): Trigger klappt SVG/PNG als Overlay auf; Auswahl + oder Tipp außerhalb schließt wieder. Auf Desktop ist der Trigger versteckt. */ +const dlGroup = document.getElementById('dlGroup'); +const dlTrigger = document.getElementById('dlTrigger'); +function closeDlMenu(){ + dlGroup.classList.remove('open'); + dlTrigger.setAttribute('aria-expanded', 'false'); +} +dlTrigger.addEventListener('click', () => { + const open = dlGroup.classList.toggle('open'); + dlTrigger.setAttribute('aria-expanded', String(open)); +}); +document.addEventListener('click', e => { + if(dlGroup.classList.contains('open') && !dlGroup.contains(e.target)) closeDlMenu(); +}); + +/* ---------- Internationalisierung (DE/EN/ES/FR) ---------- */ +const I18N = { + de: { + subtitle:"Werkbaum – Ein Text-basierter WBS/Projektstrukturplan/Feature-Tree/Requirements-Baum Diagramm-Editor.", + subtitleShort:"WBS, Feature- & Requirements-Bäume als Text", + imprint:"Impressum", + legendTooltip:"Legende ein-/ausblenden", + ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.", + discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden", + cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück", + implicitSizeTooltip:"Keine Größe angegeben – für die Kostenschätzung als M angenommen", + fullscreenTooltip:"Vollbild – Panels nutzen die ganze Fensterbreite", + brandTooltip:"„Werkbaum“ bedeutet so viel wie ‚Werk-Baum‘ — der Baum des Projektstrukturplans (WBS).", + editorTitle:"Struktur (Text)", diagramTitle:"Diagramm", + copy:"kopieren", copyDone:"kopiert ✓", copyTooltip:"Text in die Zwischenablage kopieren", + copyDiagramTooltip:"Diagramm als PNG-Bild in die Zwischenablage kopieren", + downloadDiagramTooltip:"Diagramm als SVG-Datei herunterladen (z. B. für LibreOffice: Einfügen → Bild)", + downloadPngTooltip:"Diagramm als PNG-Datei herunterladen (Rasterbild, überall einfügbar)", + downloadMenu:"Diagramm herunterladen (SVG/PNG)", + minimize:"minimieren", normal:"normal", maximize:"maximieren", + agenda:"Agenda", discarded:"verworfene", + modeHorizontal:"Horizontal – Organigramm, Diagramm über dem Editor", + modeKompakt:"Kompakt – alles nach unten, platzsparend", + modeVertikal:"Vertikal – Baum nach rechts, Diagramm neben dem Editor", + zoomOut:"verkleinern", zoomReset:"zurücksetzen", zoomIn:"vergrößern", + zoomAria:"Zoom (Strg/Cmd + Mausrad)", langMore:"weitere Sprachen", + empty:"Noch keine Struktur — einfach lostippen.", ghost:"…", + mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.", + st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", + st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen", + hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", + hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", + hint_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.", + hint_status:"Status als Kästchen nach dem Zeichen, z. B.", + hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:", + hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.", + hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.", + hint_people:"Personen mit @name — erscheinen unten rechts am Knoten." + }, + en: { + subtitle:"Werkbaum – A text-based WBS / work breakdown structure / feature-tree / requirements-tree diagram editor.", + subtitleShort:"WBS, feature & requirements trees as text", + imprint:"Imprint (Impressum)", + legendTooltip:"Show/hide legend", + ghostTooltip:"From size M upward, an item should be broken down further.", + discardedTooltip:"Show/hide discarded nodes and their subtree", + cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede", + implicitSizeTooltip:"No size given – assumed as M for the cost estimate", + fullscreenTooltip:"Full screen – panels use the full window width", + brandTooltip:"“Werkbaum” means roughly ‘work tree’ — the tree of the work breakdown structure (WBS).", + editorTitle:"Structure (text)", diagramTitle:"Diagram", + copy:"copy", copyDone:"copied ✓", copyTooltip:"Copy text to clipboard", + copyDiagramTooltip:"Copy diagram as a PNG image to the clipboard", + downloadDiagramTooltip:"Download diagram as an SVG file (e.g. for LibreOffice: Insert → Image)", + downloadPngTooltip:"Download diagram as a PNG file (raster image, insertable anywhere)", + downloadMenu:"Download diagram (SVG/PNG)", + minimize:"minimize", normal:"normal", maximize:"maximize", + agenda:"Legend", discarded:"discarded", + modeHorizontal:"Horizontal – org chart, diagram above the editor", + modeKompakt:"Compact – everything downward, space-saving", + modeVertikal:"Vertical – tree to the right, diagram beside the editor", + zoomOut:"zoom out", zoomReset:"reset", zoomIn:"zoom in", + zoomAria:"Zoom (Ctrl/Cmd + mouse wheel)", langMore:"more languages", + empty:"No structure yet — just start typing.", ghost:"…", + mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.", + st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", + st_fertig:"done", st_prod:"in production", st_verworfen:"discarded", + hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", + hint_all:"sub-task, all required", hint_any:"alternative, choose one", + hint_root:"Line without a marker = root node. Siblings should share the same marker.", + hint_status:"Status as a checkbox after the marker, e.g.", + hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:", + hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.", + hint_comment:"Comments with %% — whole line or at the end of a line.", + hint_people:"People with @name — shown at the bottom-right of the node." + }, + es: { + subtitle:"Werkbaum – Un editor de diagramas basado en texto para EDT / estructura de desglose del trabajo / árbol de características / árbol de requisitos.", + subtitleShort:"EDT y árboles de características y requisitos", + imprint:"Aviso legal (Impressum)", + legendTooltip:"Mostrar u ocultar la leyenda", + ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.", + discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol", + cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan", + implicitSizeTooltip:"Sin tamaño indicado: se asume M para el cálculo de costes", + fullscreenTooltip:"Pantalla completa – los paneles usan todo el ancho de la ventana", + brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo’ — el árbol de la estructura de desglose del trabajo (EDT).", + editorTitle:"Estructura (texto)", diagramTitle:"Diagrama", + copy:"copiar", copyDone:"copiado ✓", copyTooltip:"Copiar el texto al portapapeles", + copyDiagramTooltip:"Copiar el diagrama como imagen PNG al portapapeles", + downloadDiagramTooltip:"Descargar el diagrama como archivo SVG (p. ej. para LibreOffice: Insertar → Imagen)", + downloadPngTooltip:"Descargar el diagrama como archivo PNG (imagen de trama, insertable en cualquier lugar)", + downloadMenu:"Descargar el diagrama (SVG/PNG)", + minimize:"minimizar", normal:"normal", maximize:"maximizar", + agenda:"Leyenda", discarded:"descartados", + modeHorizontal:"Horizontal – organigrama, diagrama sobre el editor", + modeKompakt:"Compacto – todo hacia abajo, ahorra espacio", + modeVertikal:"Vertical – árbol hacia la derecha, diagrama junto al editor", + zoomOut:"alejar", zoomReset:"restablecer", zoomIn:"acercar", + zoomAria:"Zoom (Ctrl/Cmd + rueda del ratón)", langMore:"más idiomas", + empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…", + mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.", + st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", + st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado", + hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", + hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", + hint_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.", + hint_status:"Estado como casilla tras el marcador, p. ej.", + hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:", + hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.", + hint_comment:"Comentarios con %% — línea completa o al final de la línea.", + hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo." + }, + fr: { + subtitle:"Werkbaum – Un éditeur de diagrammes basé sur le texte pour WBS / organigramme des tâches / arbre de fonctionnalités / arbre d'exigences.", + subtitleShort:"WBS, arbres de fonctionnalités et d'exigences", + imprint:"Mentions légales (Impressum)", + legendTooltip:"Afficher/masquer la légende", + ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.", + discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre", + cheapTooltip:"Mettre en évidence le chemin le moins coûteux – les alternatives inutiles s'estompent", + implicitSizeTooltip:"Aucune taille indiquée – considérée comme M pour l'estimation des coûts", + fullscreenTooltip:"Plein écran – les panneaux occupent toute la largeur de la fenêtre", + brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — l’arbre de l’organigramme des tâches (WBS).", + editorTitle:"Structure (texte)", diagramTitle:"Diagramme", + copy:"copier", copyDone:"copié ✓", copyTooltip:"Copier le texte dans le presse-papiers", + copyDiagramTooltip:"Copier le diagramme comme image PNG dans le presse-papiers", + downloadDiagramTooltip:"Télécharger le diagramme en fichier SVG (p. ex. pour LibreOffice : Insertion → Image)", + downloadPngTooltip:"Télécharger le diagramme en fichier PNG (image matricielle, insérable partout)", + downloadMenu:"Télécharger le diagramme (SVG/PNG)", + minimize:"réduire", normal:"normal", maximize:"agrandir", + agenda:"Légende", discarded:"abandonnés", + modeHorizontal:"Horizontal – organigramme, diagramme au-dessus de l'éditeur", + modeKompakt:"Compact – tout vers le bas, gain de place", + modeVertikal:"Vertical – arbre vers la droite, diagramme à côté de l'éditeur", + zoomOut:"dézoomer", zoomReset:"réinitialiser", zoomIn:"zoomer", + zoomAria:"Zoom (Ctrl/Cmd + molette)", langMore:"plus de langues", + empty:"Pas encore de structure — commencez à taper.", ghost:"…", + mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.", + st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", + st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné", + hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", + hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", + hint_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.", + hint_status:"Statut sous forme de case après le marqueur, p. ex.", + hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :", + hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.", + hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.", + hint_people:"Personnes avec @nom — affichées en bas à droite du nœud." + }, + pl: { + subtitle:"Werkbaum – Edytor diagramów oparty na tekście: WBS / struktura podziału pracy / drzewo funkcji / drzewo wymagań.", + subtitleShort:"WBS, drzewa funkcji i wymagań jako tekst", + imprint:"Nota prawna (Impressum)", + legendTooltip:"Pokaż/ukryj legendę", + ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.", + discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem", + cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone", + implicitSizeTooltip:"Nie podano rozmiaru – przyjęto M do szacowania kosztów", + fullscreenTooltip:"Pełny ekran – panele wykorzystują całą szerokość okna", + brandTooltip:"„Werkbaum” znaczy mniej więcej ‚drzewo pracy’ — drzewo struktury podziału pracy (WBS).", + editorTitle:"Struktura (tekst)", diagramTitle:"Diagram", + copy:"kopiuj", copyDone:"skopiowano ✓", copyTooltip:"Kopiuj tekst do schowka", + copyDiagramTooltip:"Kopiuj diagram jako obraz PNG do schowka", + downloadDiagramTooltip:"Pobierz diagram jako plik SVG (np. dla LibreOffice: Wstaw → Obraz)", + downloadPngTooltip:"Pobierz diagram jako plik PNG (obraz rastrowy, wszędzie do wstawienia)", + downloadMenu:"Pobierz diagram (SVG/PNG)", + minimize:"minimalizuj", normal:"normalny", maximize:"maksymalizuj", + agenda:"Legenda", discarded:"odrzucone", + modeHorizontal:"Poziomo – schemat organizacyjny, diagram nad edytorem", + modeKompakt:"Kompaktowo – wszystko w dół, oszczędza miejsce", + modeVertikal:"Pionowo – drzewo w prawo, diagram obok edytora", + zoomOut:"pomniejsz", zoomReset:"resetuj", zoomIn:"powiększ", + zoomAria:"Powiększenie (Ctrl/Cmd + kółko myszy)", langMore:"więcej języków", + empty:"Brak struktury — zacznij pisać.", ghost:"…", + mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.", + st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", + st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone", + hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", + hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", + hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.", + hint_status:"Status jako pole wyboru po znaczniku, np.", + hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:", + hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.", + hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.", + hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła." + }, + ru: { + subtitle:"Werkbaum – текстовый редактор диаграмм: СДР / структура декомпозиции работ / дерево функций / дерево требований.", + subtitleShort:"СДР, деревья функций и требований", + imprint:"Выходные данные (Impressum)", + legendTooltip:"Показать/скрыть легенду", + ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.", + discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом", + cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются", + implicitSizeTooltip:"Размер не указан — для оценки затрат принят как M", + fullscreenTooltip:"Полный экран – панели занимают всю ширину окна", + brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).", + editorTitle:"Структура (текст)", diagramTitle:"Диаграмма", + copy:"копировать", copyDone:"скопировано ✓", copyTooltip:"Скопировать текст в буфер обмена", + copyDiagramTooltip:"Скопировать диаграмму как изображение PNG в буфер обмена", + downloadDiagramTooltip:"Скачать диаграмму как файл SVG (напр. для LibreOffice: Вставка → Изображение)", + downloadPngTooltip:"Скачать диаграмму как файл PNG (растровое изображение, вставляется везде)", + downloadMenu:"Скачать диаграмму (SVG/PNG)", + minimize:"свернуть", normal:"обычный", maximize:"развернуть", + agenda:"Легенда", discarded:"отклонённые", + modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором", + modeKompakt:"Компактно – всё вниз, экономит место", + modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором", + zoomOut:"уменьшить", zoomReset:"сбросить", zoomIn:"увеличить", + zoomAria:"Масштаб (Ctrl/Cmd + колесо мыши)", langMore:"ещё языки", + empty:"Пока нет структуры — просто начните печатать.", ghost:"…", + mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.", + st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", + st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено", + hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", + hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", + hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.", + hint_status:"Статус в виде флажка после маркера, напр.", + hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", + hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", + hint_comment:"Комментарии через %% — вся строка или в конце строки.", + hint_people:"Люди через @имя — показываются справа внизу узла." + }, + hi: { + subtitle:"Werkbaum – टेक्स्ट-आधारित आरेख संपादक: WBS / कार्य विभाजन संरचना / फ़ीचर-ट्री / रिक्वायरमेंट-ट्री।", + subtitleShort:"WBS, फ़ीचर और रिक्वायरमेंट ट्री", + imprint:"प्रकाशन विवरण (Impressum)", + legendTooltip:"लेजेंड दिखाएँ/छिपाएँ", + ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।", + discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ", + cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं", + implicitSizeTooltip:"कोई आकार नहीं दिया गया – लागत अनुमान के लिए M माना गया", + fullscreenTooltip:"पूर्ण स्क्रीन – पैनल पूरी विंडो चौड़ाई का उपयोग करते हैं", + brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।", + editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख", + copy:"कॉपी करें", copyDone:"कॉपी हो गया ✓", copyTooltip:"टेक्स्ट को क्लिपबोर्ड पर कॉपी करें", + copyDiagramTooltip:"आरेख को PNG छवि के रूप में क्लिपबोर्ड पर कॉपी करें", + downloadDiagramTooltip:"आरेख को SVG फ़ाइल के रूप में डाउनलोड करें (जैसे LibreOffice: सम्मिलित करें → छवि)", + downloadPngTooltip:"आरेख को PNG फ़ाइल के रूप में डाउनलोड करें (रास्टर छवि, कहीं भी सम्मिलित करने योग्य)", + downloadMenu:"आरेख डाउनलोड करें (SVG/PNG)", + minimize:"छोटा करें", normal:"सामान्य", maximize:"बड़ा करें", + agenda:"लेजेंड", discarded:"अस्वीकृत", + modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख", + modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है", + modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख", + zoomOut:"ज़ूम आउट", zoomReset:"रीसेट करें", zoomIn:"ज़ूम इन", + zoomAria:"ज़ूम (Ctrl/Cmd + माउस-व्हील)", langMore:"और भाषाएँ", + empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…", + mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।", + st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", + st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत", + hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", + hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", + hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।", + hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे", + hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", + hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", + hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।", + hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।" + }, + zh: { + subtitle:"Werkbaum – 基于文本的图表编辑器:WBS / 工作分解结构 / 功能树 / 需求树。", + subtitleShort:"WBS、功能树与需求树", + imprint:"法律声明(Impressum)", + legendTooltip:"显示/隐藏图例", + brandTooltip:"「Werkbaum」大致意为‘工作之树’——即工作分解结构(WBS)之树。", + fullscreenTooltip:"全屏——面板占据整个窗口宽度", + discardedTooltip:"显示/隐藏已放弃的节点及其子树", + cheapTooltip:"突出显示成本最低的路径——不需要的备选项将淡化", + implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计", + ghostTooltip:"从 M 号起,元素应进一步细分。", + editorTitle:"结构(文本)", diagramTitle:"图表", + copy:"复制", copyDone:"已复制 ✓", copyTooltip:"将文本复制到剪贴板", + copyDiagramTooltip:"将图表作为 PNG 图片复制到剪贴板", + downloadDiagramTooltip:"将图表下载为 SVG 文件(例如用于 LibreOffice:插入 → 图像)", + downloadPngTooltip:"将图表下载为 PNG 文件(位图,可在任何地方插入)", + downloadMenu:"下载图表(SVG/PNG)", + minimize:"最小化", normal:"正常", maximize:"最大化", + agenda:"图例", discarded:"已放弃", + modeHorizontal:"横向——组织结构图,图表在编辑器上方", + modeKompakt:"紧凑——全部向下,节省空间", + modeVertikal:"纵向——树向右展开,图表在编辑器旁边", + zoomOut:"缩小", zoomReset:"重置", zoomIn:"放大", + zoomAria:"缩放(Ctrl/Cmd + 鼠标滚轮)", langMore:"更多语言", + empty:"还没有结构——直接开始输入吧。", ghost:"…", + mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。", + st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", + st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃", + hint_indent:"缩进(2 个空格或制表符)定义层级。", + hint_all:"子任务,全部必需", hint_any:"备选项,择其一", + hint_root:"无标记的行 = 根节点。同级应使用相同的标记。", + hint_status:"在标记后用方框表示状态,例如", + hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", + hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", + hint_comment:"用 %% 注释——整行或行尾。", + hint_people:"用 @姓名 表示人员——显示在节点右下角。" + }, + ja: { + subtitle:"Werkbaum – テキストベースの図エディター:WBS / 作業分解構成図 / フィーチャーツリー / 要件ツリー。", + subtitleShort:"WBS・機能ツリー・要件ツリー", + imprint:"運営者情報(Impressum)", + legendTooltip:"凡例を表示/非表示", + brandTooltip:"「Werkbaum」はおおよそ『作業の木』の意味 — 作業分解構成図(WBS)のツリーです。", + fullscreenTooltip:"全画面 — パネルがウィンドウ幅いっぱいを使用", + discardedTooltip:"破棄したノードとその下位ツリーを表示/非表示", + cheapTooltip:"最も低コストの経路を強調 – 不要な選択肢は控えめに表示", + implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う", + ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。", + editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム", + copy:"コピー", copyDone:"コピーしました ✓", copyTooltip:"テキストをクリップボードにコピー", + copyDiagramTooltip:"ダイアグラムを PNG 画像としてクリップボードにコピー", + downloadDiagramTooltip:"ダイアグラムを SVG ファイルとしてダウンロード(例:LibreOffice の 挿入 → 画像)", + downloadPngTooltip:"ダイアグラムを PNG ファイルとしてダウンロード(ラスター画像、どこにでも挿入可能)", + downloadMenu:"ダイアグラムをダウンロード(SVG/PNG)", + minimize:"最小化", normal:"標準", maximize:"最大化", + agenda:"凡例", discarded:"破棄", + modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上", + modeKompakt:"コンパクト — すべて下方向、省スペース", + modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横", + zoomOut:"縮小", zoomReset:"リセット", zoomIn:"拡大", + zoomAria:"ズーム(Ctrl/Cmd + マウスホイール)", langMore:"その他の言語", + empty:"まだ構造がありません — 入力を始めてください。", ghost:"…", + mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。", + st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", + st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄", + hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", + hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", + hint_root:"マーカーのない行 = ルートノード。兄弟は同じマーカーを使うべきです。", + hint_status:"マーカーの後にチェックボックスで状態、例:", + hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", + hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", + hint_comment:"%% でコメント — 行全体または行末。", + hint_people:"@名前 で担当者 — ノードの右下に表示されます。" + } +}; +let lang = 'de'; +function t(key, vars){ + let s = (I18N[lang] && I18N[lang][key]) ?? I18N.de[key] ?? key; + if(vars) for(const k in vars) s = s.split('{'+k+'}').join(vars[k]); + return s; +} +function buildHint(){ + const chip = (key, code) => `${code} ${esc(t('st_'+key))}`; + return `${esc(t('hint_indent'))}
+ -  ${esc(t('hint_all'))} (all of)
+ |  ${esc(t('hint_any'))} (any of)
+ ${esc(t('hint_root'))}
+ ${esc(t('hint_status'))} - [~] Frontend: +
+ ${chip('idee','[?]')} + ${chip('geplant','[ ]')} + ${chip('arbeit','[~]')} + ${chip('durchstich','[/]')} + ${chip('fertig','[x]')} + ${chip('prod','[^]')} + ${chip('verworfen','[-]')} +
+ ${esc(t('hint_size'))}
+ - [ ] Backend (L) https://…
+ ${esc(t('hint_break'))}
+ ${esc(t('hint_comment'))} + ${esc(t('hint_people'))}`; +} +function applyLang(l){ + lang = l; + document.documentElement.lang = l; + document.querySelectorAll('[data-i18n]').forEach(el => el.textContent = t(el.dataset.i18n)); + document.querySelectorAll('[data-i18n-title]').forEach(el => el.title = t(el.dataset.i18nTitle)); + document.querySelectorAll('[data-i18n-aria]').forEach(el => el.setAttribute('aria-label', t(el.dataset.i18nAria))); + document.getElementById('hint').innerHTML = buildHint(); + document.querySelectorAll('.langsel button[data-lang]').forEach(b => b.classList.toggle('active', b.dataset.lang===l)); + /* Ist eine aufklappbare Sprache aktiv, den erweiterten Bereich offen halten. + Auf kleinem Bildschirm nicht — dort bleibt die Leiste schlank (EN + aktive + Sprache reichen; die aktive wird per CSS auch als „extra“ eingeblendet). */ + if(LANG_EXTRA.includes(l) && !isMobile()){ + langsel.classList.add('expanded'); + langMore.setAttribute('aria-expanded', 'true'); + } + render(); + try{ localStorage.setItem('werkbaum-lang', l); }catch(_){} +} +const langsel = document.querySelector('.langsel'); +const langMore = document.getElementById('langMore'); +const LANG_EXTRA = ['pl','ru','hi','zh','ja']; +langMore.addEventListener('click', () => { + const exp = langsel.classList.toggle('expanded'); + langMore.setAttribute('aria-expanded', String(exp)); +}); +function collapseLangsel(){ + langsel.classList.remove('expanded'); + langMore.setAttribute('aria-expanded', 'false'); +} +document.querySelectorAll('.langsel button[data-lang]').forEach(b => { + b.addEventListener('click', () => { + /* Mobil: eingeklappt ist nur die aktive Sprache sichtbar — ein Tipp klappt + die volle Leiste als Overlay auf; erst im aufgeklappten Zustand wählt ein + Tipp die Sprache und klappt wieder ein. */ + if(isMobile() && !langsel.classList.contains('expanded')){ + langsel.classList.add('expanded'); + langMore.setAttribute('aria-expanded', 'true'); + return; + } + applyLang(b.dataset.lang); + if(isMobile()) collapseLangsel(); + }); +}); +/* Mobil: Tipp außerhalb der aufgeklappten Leiste schließt sie wieder. */ +document.addEventListener('click', e => { + if(isMobile() && langsel.classList.contains('expanded') && !langsel.contains(e.target)){ + collapseLangsel(); + } +}); + +/* ---------- Vollbild: Panels über die ganze Fensterbreite ---------- */ +const fsBtn = document.getElementById('fsToggle'); +fsBtn.addEventListener('click', () => { + const on = document.body.classList.toggle('fullscreen'); + fsBtn.classList.toggle('active', on); + fsBtn.setAttribute('aria-pressed', String(on)); + saveUI(); +}); + +/* ---------- GUI-Zustand + Editortext im Browser sichern ---------- + Noch kein Backend: Einstellungen (Modus, verworfene, Aufteilung, Zoom, + Vollbild) und der Editortext bleiben per localStorage über Neuladen + erhalten. Sprache liegt weiterhin in 'werkbaum-lang'. */ +const LS_UI = 'werkbaum-ui', LS_SRC = 'werkbaum-src'; +let restoring = false; /* unterdrückt Speichern während des Wiederherstellens */ +let hadStoredUI = false; /* gab es beim Laden schon gespeicherte GUI-Einstellungen? */ +function saveSrc(){ + if(restoring) return; + try{ localStorage.setItem(LS_SRC, src.value); }catch(_){} +} +function saveUI(){ + if(restoring) return; + try{ + const modeEl = document.querySelector('input[name="layout"]:checked'); + localStorage.setItem(LS_UI, JSON.stringify({ + mode: modeEl ? modeEl.value : 'horizontal', + discarded: discardedShown(), + cheapPath: cheapPathOn, + split: splitState, + col: app.style.getPropertyValue('--col') || null, + drow: app.style.getPropertyValue('--drow') || null, + zoom: zoom, + fullscreen: document.body.classList.contains('fullscreen') + })); + }catch(_){} +} +function restoreState(){ + restoring = true; + let savedSrc = null, ui = null; + try{ savedSrc = localStorage.getItem(LS_SRC); }catch(_){} + try{ ui = JSON.parse(localStorage.getItem(LS_UI) || 'null'); }catch(_){} + hadStoredUI = !!ui; + src.value = (savedSrc !== null) ? savedSrc : INITIAL; /* leerer String bleibt leer */ + const mode = (ui && ui.mode) || 'horizontal'; + const modeEl = document.querySelector('input[name="layout"][value="' + mode + '"]'); + if(modeEl) modeEl.checked = true; + if(ui){ + if(typeof ui.discarded === 'boolean') setDiscarded(ui.discarded); + if(typeof ui.cheapPath === 'boolean'){ + cheapPathOn = ui.cheapPath; + cheapBtn.setAttribute('aria-pressed', cheapPathOn ? 'true' : 'false'); + } + if(typeof ui.zoom === 'number') zoom = ui.zoom; + if(ui.split) splitState = ui.split; + if(ui.fullscreen){ + document.body.classList.add('fullscreen'); + fsBtn.classList.add('active'); + fsBtn.setAttribute('aria-pressed', 'true'); + } + } + applyLayout(mode); /* setzt Ausrichtung + ruft applySplit() */ + if(splitState === 'custom'){ /* freie Drag-Größen nach applySplit wieder setzen */ + if(ui && ui.col) app.style.setProperty('--col', ui.col); + if(ui && ui.drow) app.style.setProperty('--drow', ui.drow); + } + applyZoom(); + restoring = false; +} + +/* ---------- Kleiner Bildschirm: nur ein Panel, schlanke Sprachwahl, Vollbild ---------- */ +const mqMobile = window.matchMedia('(max-width:640px)'); +function isMobile(){ return mqMobile.matches; } + +/* Exakte sichtbare Höhe (--app-height) aus window.visualViewport ableiten. + Nötig, weil manche Browser (z. B. Brave) ihre untere Leiste als Overlay + zeichnen: 100dvh meldet dann weiterhin die volle Höhe, und Footer/Editor- + Titelzeile verschwinden dahinter. visualViewport.height liefert die wirklich + sichtbare Fläche. Fällt es weg, greift die CSS-Kaskade (dvh/vh). */ +function setAppHeight(){ + const vv = window.visualViewport; + const h = vv ? vv.height : window.innerHeight; + document.documentElement.style.setProperty('--app-height', Math.round(h) + 'px'); +} +setAppHeight(); +window.addEventListener('resize', setAppHeight); +window.addEventListener('orientationchange', setAppHeight); +if(window.visualViewport){ + window.visualViewport.addEventListener('resize', setAppHeight); +} +const legendBtn = document.getElementById('legendBtn'); +const agenda = document.querySelector('.agenda'); +legendBtn.addEventListener('click', () => { + agenda.open = !agenda.open; + legendBtn.classList.toggle('active', agenda.open); +}); +agenda.addEventListener('toggle', () => legendBtn.classList.toggle('active', agenda.open)); +/* Modus-Wähler auf kleinem Bildschirm: es ist nur das aktive Icon sichtbar, + Tippen schaltet zum nächsten Modus (reihum). Auf normaler Größe bleibt es + der Dreier-Umschalter — dort kehrt der Handler sofort zurück. */ +const seg = document.querySelector('.seg'); +const LAYOUT_MODES = ['horizontal','kompakt','vertikal']; +seg.addEventListener('click', e => { + if(!isMobile()) return; + e.preventDefault(); + e.stopPropagation(); /* nicht zum Titelzeilen-Maximieren durchreichen */ + const cur = document.querySelector('input[name="layout"]:checked').value; + const next = LAYOUT_MODES[(LAYOUT_MODES.indexOf(cur) + 1) % LAYOUT_MODES.length]; + const r = document.querySelector('input[name="layout"][value="' + next + '"]'); + r.checked = true; + r.dispatchEvent(new Event('change', {bubbles:true})); +}); +function applyMobile(){ + const m = isMobile(); + document.body.classList.toggle('mobile', m); + if(m) collapseLangsel(); /* Sprachleiste eingeklappt starten (Overlay-Logik) */ + if(!m) return; + /* Freie Aufteilung (kontinuierlich): kein Desktop-Collapse. Ist noch keine + Aufteilung gesetzt (frisch oder Alt-Preset), Diagramm maximieren (Editor + bleibt als Titelzeile); eine gespeicherte --drow bleibt erhalten. */ + clearCollapse(); + syncPanelMins(); /* Grid-Minima = aktuelle Kopfzeilenhöhen */ + if(app.style.getPropertyValue('--drow')) splitState = 'custom'; + else setMobileDrow(mobileMaxDrow(), true); + /* Default Vollbild auf kleinem Bildschirm — nur ohne gespeicherte Wahl. */ + if(!hadStoredUI && !document.body.classList.contains('fullscreen')){ + document.body.classList.add('fullscreen'); + fsBtn.classList.add('active'); + fsBtn.setAttribute('aria-pressed', 'true'); + saveUI(); + } +} +mqMobile.addEventListener('change', applyMobile); + +restoreState(); /* Editortext + GUI-Zustand aus dem Browser wiederherstellen */ +let startLang = 'de'; +try{ startLang = localStorage.getItem('werkbaum-lang') || 'de'; }catch(_){} +applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ +applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..74f1fef --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,607 @@ + :root{ + --paper:#F3F6F9; + --grid:rgba(36,52,71,.055); + --ink:#243447; + --line:#41556E; + --card:#FFFFFF; + --muted:#6B7A8C; + --or:#0F766E; + --warn:#B45309; + --r:8px; + } + *{box-sizing:border-box;margin:0;padding:0} + body{ + font-family:'IBM Plex Sans',system-ui,sans-serif; + color:var(--ink); + background: + repeating-linear-gradient(0deg,transparent 0 23px,var(--grid) 23px 24px), + repeating-linear-gradient(90deg,transparent 0 23px,var(--grid) 23px 24px), + var(--paper); + /* Mobile Viewport-Höhe: 100vh ist die *große* Höhe (inkl. Fläche hinter + der unteren Browserleiste) — Footer/Editor-Titelzeile rutschen dahinter. + 100dvh folgt der sichtbaren Fläche, versagt aber bei Browsern mit + Overlay-Leiste (z. B. Brave). Deshalb setzt JS zusätzlich --app-height + aus window.visualViewport.height (die wirklich sichtbare Höhe). Reihen- + folge = Fallback-Kaskade: vh → dvh → exakte JS-Höhe. */ + height:100vh; + height:100dvh; + height:var(--app-height, 100dvh); + overflow:hidden; + display:flex;flex-direction:column; + padding:24px 20px 12px; + } + header{max-width:1200px;width:100%;margin:0 auto 16px;flex:0 0 auto} + .header-row{display:flex;align-items:center;justify-content:space-between;gap:16px} + .header-tools{display:flex;align-items:center;gap:12px} + .fsbtn{ + display:inline-flex;align-items:center;justify-content:center; + width:30px;height:29px;padding:0;cursor:pointer; + border:1px solid rgba(36,52,71,.2);border-radius:8px; + background:var(--card);color:var(--muted); + } + .fsbtn svg{display:block} + .fsbtn:hover{border-color:var(--or);color:var(--or)} + .fsbtn.active{background:var(--or);color:#fff;border-color:var(--or)} + /* Vollbild: Panels nutzen fast die ganze Fensterbreite; es bleibt ein + schmaler Rand (halber Panel-Zwischenraum = 7 px), damit es nicht gedrängt + wirkt. */ + body.fullscreen{padding-left:7px;padding-right:7px} + body.fullscreen header, + body.fullscreen .app, + body.fullscreen .site-footer{max-width:none} + h1{font-size:1.25rem;font-weight:600;letter-spacing:-.01em} + header p{margin-top:4px;color:var(--muted);font-size:.9rem} + .sub-short{display:none} /* Kurzfassung nur auf kleinem Bildschirm */ + .langsel{ + display:inline-flex;flex:0 0 auto; + border:1px solid rgba(36,52,71,.2);border-radius:999px;overflow:hidden; + } + .langsel button{ + border:0;background:var(--card);cursor:pointer;color:var(--muted); + font-family:'IBM Plex Mono',monospace;font-size:.72rem;letter-spacing:.04em; + padding:5px 10px; + } + .langsel button + button{border-left:1px solid rgba(36,52,71,.14)} + .langsel button:hover:not(.active){color:var(--or)} + .langsel button.active{background:var(--or);color:#fff} + .langmore{letter-spacing:0;padding:5px 8px;font-weight:600} + .langsel .lang-extra{display:none} /* PL/RU/HI erst nach Aufklappen */ + .langsel.expanded .lang-extra{display:block} + .site-footer{ + max-width:1200px;margin:14px auto 0; + color:var(--muted);font-size:.78rem;text-align:center; + } + .site-footer a{color:var(--or);text-decoration:none} + .site-footer a:hover{text-decoration:underline} + .site-footer .vibe{cursor:help;text-decoration:underline dotted;text-underline-offset:2px} + + /* Horizontal (Toggle aus): Diagramm oben, Editor unten — je volle Breite. + Vertikal (Toggle an, .side): Editor links, Diagramm rechts. + Zwischen beiden liegt ein verschiebbarer Gutter (Splitter). */ + .app{ + max-width:1200px;width:100%;margin:0 auto; + flex:1 1 auto;min-height:0; + display:grid;gap:0; + grid-template-columns:minmax(0,1fr); + grid-template-rows:var(--drow,60%) 14px minmax(0,1fr); + grid-template-areas:"diagram" "gutter" "editor"; + align-items:stretch; + } + .app.side{ + grid-template-columns:var(--col,360px) 14px minmax(0,1fr); + grid-template-rows:minmax(0,1fr); + grid-template-areas:"editor gutter diagram"; + } + .panel.editor{grid-area:editor} + .panel.right{grid-area:diagram} + .gutter{grid-area:gutter} + /* Splitter/Gutter: stapelweise horizontal, seitlich vertikal. */ + .gutter{ + position:relative;align-self:stretch;justify-self:stretch; + height:14px;cursor:row-resize;touch-action:none; + } + .app.side .gutter{height:auto;width:14px;cursor:col-resize} + .gutter::before{ + content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%); + width:46px;height:4px;border-radius:999px;background:rgba(36,52,71,.16); + transition:background .15s; + } + .app.side .gutter::before{width:4px;height:46px} + .gutter:hover::before,.gutter.dragging::before{background:var(--or)} + + /* Minimieren: Panel auf seine Titelzeile schrumpfen, Körper ausblenden. + 'a' = Diagramm minimiert (Editor maximal), 'b' = Editor minimiert. */ + .panel.collapsed .editor-body, + .panel.collapsed .diagram, + .panel.collapsed .warnings{display:none} + .panel.collapsed .copybtn, + .panel.collapsed .switch, + .panel.collapsed .seg{display:none} + .panel.collapsed .panel-head{cursor:pointer} + .panel.collapsed .panel-head>span{cursor:pointer} + /* Gestapelt: kollabiertes Panel = Titelzeile (auto), anderes füllt. */ + .app:not(.side).collapse-diagram{grid-template-rows:auto 14px minmax(0,1fr)} + .app:not(.side).collapse-editor{grid-template-rows:minmax(0,1fr) 14px auto} + /* Seitlich: schmaler vertikaler Streifen mit gedrehter Beschriftung. */ + .app.side.collapse-editor{grid-template-columns:auto 14px minmax(0,1fr)} + .app.side.collapse-diagram{grid-template-columns:minmax(0,1fr) 14px auto} + .app.side .panel.collapsed{align-self:stretch} + .app.side .panel.collapsed .panel-head{ + flex-direction:column;align-items:center;gap:12px; + height:100%;padding:12px 6px;border-bottom:0; + } + .app.side .panel.collapsed .panel-head>span{ + writing-mode:vertical-rl;transform:rotate(180deg); + margin:0;white-space:nowrap; + } + .app.side .panel.collapsed .winbtns{flex-direction:column} + + /* Schmaler Viewport: Seitenmodus fällt auf gestapelt zurück. */ + @media (max-width:820px){ + .app.side{ + grid-template-columns:minmax(0,1fr); + grid-template-rows:var(--drow,60%) 14px minmax(0,1fr); + grid-template-areas:"diagram" "gutter" "editor"; + } + .app.side.collapse-diagram{grid-template-columns:minmax(0,1fr);grid-template-rows:auto 14px minmax(0,1fr)} + .app.side.collapse-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) 14px auto} + .app.side .panel.collapsed .panel-head{flex-direction:row;align-items:center;height:auto;padding:10px 16px} + .app.side .panel.collapsed .panel-head>span{writing-mode:horizontal-tb;transform:none} + .app.side .panel.collapsed .winbtns{flex-direction:row} + .app.side .gutter{height:14px;width:auto;cursor:row-resize} + .app.side .gutter::before{width:46px;height:4px} + } + + /* Editor-Innenaufbau: Textarea + aufklappbare Agenda. + Horizontal nebeneinander, vertikal (.side) untereinander. */ + .editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0} + .editor-body textarea{flex:1 1 auto;min-width:0;min-height:0} + .agenda{ + flex:0 0 auto; + border-left:1px dashed rgba(36,52,71,.18); + display:flex;flex-direction:column; + } + .agenda[open]{width:300px} + .agenda>summary{ + list-style:none;cursor:pointer;white-space:nowrap; + padding:10px 16px; + font-family:'IBM Plex Mono',monospace; + font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted); + display:flex;align-items:center;gap:7px; + } + .agenda>summary::-webkit-details-marker{display:none} + .agenda>summary::before{content:'▸';display:inline-block;transition:transform .15s} + .agenda[open]>summary::before{transform:rotate(90deg)} + .agenda{min-height:0} + .agenda[open]{overflow:hidden} + .agenda .hint{border-top:0;flex:1 1 auto;min-height:0;overflow:auto} + .side .editor-body{flex-direction:column;align-items:stretch} + .side .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)} + .side .agenda[open]{width:auto} + + .panel{ + background:var(--card); + border:1px solid rgba(15,118,110,.30); /* dezentes Petrol */ + border-radius:12px; + overflow:hidden; + display:flex;flex-direction:column;min-height:0;min-width:0; + } + .panel-head{ + flex:0 0 auto; + padding:10px 16px; + display:flex;justify-content:space-between;align-items:center;gap:14px; + font-family:'IBM Plex Mono',monospace; + font-size:.7rem;letter-spacing:.08em;text-transform:uppercase; + color:var(--muted); + border-bottom:1px solid rgba(36,52,71,.1); + flex-wrap:wrap; + } + .panel-head>span{margin-right:auto} + .switch{ + display:flex;align-items:center;gap:7px;cursor:pointer; + text-transform:none;letter-spacing:0; + font-family:'IBM Plex Sans',sans-serif;font-size:.78rem;color:var(--muted); + } + .switch input{position:absolute;opacity:0;width:0;height:0} + .switch i{ + width:30px;height:17px;border-radius:999px;background:#C6CFD8; + position:relative;transition:background .15s;flex:0 0 auto; + } + .switch i::before{ + content:'';position:absolute;top:2px;left:2px;width:13px;height:13px; + border-radius:50%;background:#fff;transition:left .15s; + box-shadow:0 1px 2px rgba(36,52,71,.25); + } + .switch input:checked + i{background:var(--or)} + .switch input:checked + i::before{left:15px} + .seg{ + display:inline-flex;align-items:stretch; + border:1px solid rgba(36,52,71,.2);border-radius:999px;overflow:hidden; + } + .seg label{display:flex;cursor:pointer} + .seg input{position:absolute;opacity:0;width:0;height:0} + .seg span{ + display:grid;place-items:center;padding:4px 10px;color:var(--muted); + transition:background .15s,color .15s; + } + .seg span svg{display:block} + .seg label + label span{border-left:1px solid rgba(36,52,71,.14)} + .seg input:checked + span{background:var(--or);color:#fff} + .seg input:focus-visible + span{outline:2px solid var(--or);outline-offset:-2px} + .winbtns{display:inline-flex;gap:4px;align-items:center} + .winbtn{ + width:19px;height:19px;display:grid;place-items:center; + border:1px solid rgba(36,52,71,.2);border-radius:5px; + background:var(--card);color:var(--muted);cursor:pointer; + font-size:.72rem;line-height:1;padding:0; + } + .winbtn:hover{border-color:var(--or);color:var(--or)} + .winbtn.active{background:var(--or);color:#fff;border-color:var(--or)} + .copybtn{ + color:var(--muted);background:var(--card); + border:1px solid rgba(36,52,71,.2);border-radius:6px; + padding:3px 6px;cursor:pointer; + display:inline-flex;align-items:center;justify-content:center; + } + .copybtn:hover,.copybtn.done{border-color:var(--or);color:var(--or)} + .copybtn svg{display:block;width:15px;height:15px} + .copybtn .ic-done{display:none} + .copybtn.done .ic-copy{display:none} + .copybtn.done .ic-done{display:block} + .dlbtn{gap:3px} + .dl-label{font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;letter-spacing:.02em} + /* Download-Gruppe: Desktop zeigt SVG/PNG inline, der Trigger ist versteckt. + Auf Mobil (body.mobile, s. u.) klappt sie als Overlay-Menü auf. */ + .dlgroup{display:inline-flex;align-items:center;gap:3px} + .dlmenu{display:inline-flex;gap:3px} + .dltrigger{display:none} + .legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */ + .togglebtn[aria-pressed="true"]{background:var(--or);color:#fff;border-color:var(--or)} + .togglebtn[aria-pressed="true"]:hover{color:#fff} + textarea{ + display:block;width:100%;min-height:0; + border:0;outline:none;resize:none; + padding:14px 16px; + font-family:'IBM Plex Mono',monospace; + font-size:.85rem;line-height:1.7; + color:var(--ink);background:var(--card); + tab-size:2; + } + .hint{ + padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18); + font-size:.8rem;color:var(--muted);line-height:1.7; + } + .hint code{ + font-family:'IBM Plex Mono',monospace;font-size:.75rem; + background:var(--paper);border:1px solid rgba(36,52,71,.12); + border-radius:4px;padding:0 5px; + } + .hint .or-code{color:var(--or);border-color:rgba(15,118,110,.4)} + + .diagram{ + flex:1 1 auto;min-height:0; + padding:28px 16px 24px; + overflow:auto; + background:transparent; + } + .panel.right{background:rgba(255,255,255,.6);position:relative} + .warnings{padding:0 16px 14px;font-size:.8rem;color:var(--warn)} + .warnings div{margin-top:4px} + .empty{color:var(--muted);font-size:.85rem;padding:8px 4px} + + /* Zoom-Steuerung: schwebt unten rechts über dem Diagramm. */ + .zoomctl{ + position:absolute;right:14px;bottom:14px;z-index:5; + display:inline-flex;align-items:center;gap:2px;padding:2px; + background:var(--card);border:1px solid rgba(36,52,71,.18); + border-radius:8px;box-shadow:0 1px 4px rgba(36,52,71,.14); + } + .zoomctl button{ + border:0;background:transparent;cursor:pointer;color:var(--muted); + font-family:'IBM Plex Mono',monospace;font-size:.82rem;line-height:1; + padding:4px 8px;border-radius:6px;min-width:30px; + } + #zoomReset{min-width:48px;font-size:.72rem} + .zoomctl button:hover{background:var(--paper);color:var(--or)} + .panel.collapsed .zoomctl{display:none} + + /* ---------- Baum ---------- */ + .tree{width:max-content;margin:0 auto;list-style:none;position:relative} + .tree ul{list-style:none} + .tree li{display:flex;flex-direction:column;align-items:center;position:relative} + .tree > li + li{margin-top:36px} + + .node{ + position:relative; + color:var(--ink); + background:var(--card); + border:1.5px solid var(--line); + border-radius:var(--r); + padding:7px 14px; + font-size:.88rem;font-weight:500;line-height:1.3; + white-space:nowrap; + box-shadow:0 1px 2px rgba(36,52,71,.08); + } + a.node{text-decoration:none;cursor:pointer} + a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)} + .ext{font-size:.72em;margin-left:5px;opacity:.55} + .size{ + position:absolute;top:-9px;right:-9px; + font-family:'IBM Plex Mono',monospace; + font-size:.58rem;font-weight:500;letter-spacing:.02em; + background:var(--or);color:#fff; + border:1.5px solid var(--card); + border-radius:999px;padding:1px 6px;line-height:1.4; + } + .tags{ + position:absolute;right:-9px;bottom:-9px; + display:flex;gap:3px; + } + .tag{ + font-family:'IBM Plex Mono',monospace; + font-size:.58rem;font-weight:500;letter-spacing:.02em; + background:#EEF1F4;color:var(--ink); + border:1.5px solid var(--line); + border-radius:999px;padding:1px 6px;line-height:1.4; + box-shadow:0 0 0 1.5px var(--card); + } + .ghost-node{ + position:relative;margin-top:14px; + border:1.5px dashed var(--warn); + color:var(--warn);background:rgba(180,83,9,.06); + border-radius:var(--r);padding:4px 12px; + font-size:.74rem;font-style:italic;white-space:nowrap; + } + .ghost-node::before{ + content:'';position:absolute;top:-14px;left:50%;height:14px; + border-left:2px dashed var(--warn); + } + .root-node{background:var(--ink);border-color:var(--ink);color:#fff;font-weight:600;padding:9px 20px} + + /* Günstigster Pfad (Inversion): nicht benötigte Knoten (nicht-gewählte + any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt); + der günstige Pfad hebt sich dadurch von allein ab. */ + .cheap-on .node:not(.cheap){opacity:.32;filter:saturate(.4)} + /* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */ + .size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} + /* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch + die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen + zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken + sichtbar), eine abgetönte Kopie liegt DAVOR (deutet den Verlauf beim + Durchschreiten von Knoten nur schwach an). Kleine Stationspunkte an den + Blättern zeigen, welche Knoten wirklich auf dem Pfad enden — durchquerte + Fremdknoten tragen keinen Punkt. Reihenfolge im DOM steuert vorn/hinten. */ + .cheap-overlay{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} + .cheap-path{fill:none;stroke:var(--or);stroke-width:2.5;stroke-linecap:round; + stroke-dasharray:8 6;opacity:.9} + .cheap-path.faint{opacity:.2} + /* Stationspunkt: groß, aber blass gefüllt — der Knotentext bleibt lesbar */ + .cheap-dot{fill:var(--or);fill-opacity:.2;stroke:var(--or);stroke-opacity:.35;stroke-width:1.5} + + /* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */ + ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px} + ul.and::before{ + content:'';position:absolute;top:0;left:50%;height:14px;border-left:2px solid var(--line); + } + ul.and>li{padding:16px 12px 0} + ul.and>li::before, + ul.and>li::after{ + content:'';position:absolute;top:0;right:50%; + border-top:2px solid var(--line);width:50%;height:16px; + } + ul.and>li::after{right:auto;left:50%;border-left:2px solid var(--line)} + ul.and>li:only-child{padding-top:14px} + ul.and>li:only-child::before,ul.and>li:only-child::after{border-top:none} + ul.and>li:only-child::after{border-left:2px solid var(--line);height:14px} + ul.and>li:first-child::before, + ul.and>li:last-child::after{border:0 none} + ul.and>li:last-child::before{border-right:2px solid var(--line);border-radius:0 6px 0 0} + ul.and>li:first-child::after{border-radius:6px 0 0 0} + + /* --- ANY OF: durchgehend gestrichelte graue Linien (Haupt- + Abzweige) --- */ + li.has-or{align-items:flex-start} + ul.or{ + display:flex;flex-direction:column; + align-items:flex-start; + position:relative; + padding-top:14px; + } + ul.or::before{ + content:'';position:absolute;top:0;left:9px;height:14px;border-left:2px dashed var(--muted); + } + ul.or>li{position:relative;align-items:flex-start;padding:5px 0 5px 30px} + /* Abzweig auf Höhe der Knoten-Mitte (5px li-Padding + halbe Knotenhöhe), nicht Mitte des Teilbaums */ + ul.or>li::before{ + content:'';position:absolute;left:9px;top:23px;width:21px;border-top:2px dashed var(--muted); + } + ul.or>li::after{ + content:'';position:absolute;left:9px;top:0;height:100%;border-left:2px dashed var(--muted); + } + ul.or>li:last-child::after{height:23px} + /* all-of-Teilbaum UNTER einer any-of-Alternative (nur horizontal): + schmal transponiert stapeln — Kinder untereinander mit linkem, solidem + Verteiler (wie im kompakten Modus), statt breitem Fächer. Sonst zwingt der + breite Fächer ein Zentrieren, das den Alternativknoten (und damit den + ganzen Elternbaum) weit nach rechts schiebt. Passt zugleich stilistisch + zur gestapelten any-of-Spalte darüber. Vertikal/kompakt haben ihre eigene + `ul.and`-Behandlung und bleiben unberührt. */ + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and{ + flex-direction:column;align-items:flex-start;padding-top:14px; + } + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and::before{left:9px;height:14px} + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li{padding:5px 0 5px 30px} + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li::before{ + left:9px;right:auto;top:23px;width:21px;height:0; + border:0;border-top:2px solid var(--line);border-radius:0; + } + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li::after{ + left:9px;right:auto;top:0;width:0;height:100%; + border:0;border-left:2px solid var(--line);border-radius:0; + } + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li:last-child::after{height:23px} + ul.or .node{ + border-color:var(--muted); + box-shadow:none; + font-weight:400; + } + + /* ---------- Status (Pastell) ---------- */ + .node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)} + .node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)} + .node.st-arbeit {background:#FADDE4;border-color:#D897A8;color:var(--ink)} + .node.st-durchstich{background:#FBF2CE;border-color:#D9BE63;color:var(--ink)} + .node.st-fertig {background:#DCF1DE;border-color:#86C293;color:var(--ink)} + .node.st-prod {background:#DBEAF8;border-color:#85ACD7;color:var(--ink)} + .node.st-verworfen{ + background:#F1F2F4;border-color:#B3BAC2;border-style:dashed; + color:var(--muted);text-decoration:line-through;box-shadow:none; + } + + .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} + .chip{ + font-family:'IBM Plex Mono',monospace;font-size:.72rem; + border:1.5px solid;border-radius:6px;padding:2px 8px; + color:var(--ink);white-space:nowrap; + } + .chip.st-idee {background:#EBEDEF;border-color:#A2ABB5} + .chip.st-geplant {background:#EBE4F6;border-color:#A991D4} + .chip.st-arbeit {background:#FADDE4;border-color:#D897A8} + .chip.st-durchstich{background:#FBF2CE;border-color:#D9BE63} + .chip.st-fertig {background:#DCF1DE;border-color:#86C293} + .chip.st-prod {background:#DBEAF8;border-color:#85ACD7} + .chip.st-verworfen{background:#F1F2F4;border-color:#B3BAC2;border-style:dashed;color:var(--muted);text-decoration:line-through} + + /* ---------- Transponierte Modi (vertikal + kompakt) ---------- */ + /* Gemeinsame Basis: Kinder untereinander, Sammelleiste links, + Abzweig auf Knotenmitte (23-px-Offset). */ + .tree.vertical, .tree.kompakt{margin:0} + .tree.vertical li, .tree.kompakt li{align-items:flex-start} + .tree.vertical ul.and, .tree.kompakt ul.and{flex-direction:column;align-items:flex-start;padding-top:14px} + .tree.vertical ul.and::before, .tree.kompakt ul.and::before{left:9px;height:14px} + .tree.vertical ul.and>li, .tree.kompakt ul.and>li{padding:5px 0 5px 30px} + .tree.vertical ul.and>li::before, .tree.kompakt ul.and>li::before{ + left:9px;right:auto;top:23px;width:21px;height:0; + border:0;border-top:2px solid var(--line);border-radius:0; + } + .tree.vertical ul.and>li::after, .tree.kompakt ul.and>li::after{ + left:9px;right:auto;top:0;width:0;height:100%; + border:0;border-left:2px solid var(--line);border-radius:0; + } + .tree.vertical ul.and>li:last-child::after, .tree.kompakt ul.and>li:last-child::after{height:23px} + + /* Nur vertikal: echter transponierter, ZENTRIERTER LR-Baum — analog zur + horizontalen Darstellung, nur um 90° gedreht. Der Überknoten sitzt + vertikal mittig zu seiner Kindergruppe; die all-of-Verbindung tritt rechts + auf halber Höhe aus, eine vertikale Sammelleiste verteilt auf die Kinder. + Kompakt bleibt unberührt (führt all-of nach unten). */ + .tree.vertical li.has-and{flex-direction:row;align-items:center} + .tree.vertical li.has-and>ul.and{padding-top:0} + /* Stub von der Parent-Mitte (halbe Höhe) zur vertikalen Sammelleiste */ + .tree.vertical li.has-and>ul.and::before{ + left:0;top:50%;width:9px;height:0; + border-left:0;border-top:2px solid var(--line); + } + /* Abzweig trifft die tatsächliche Knotenmitte des Kindes: + Blatt- und Any-of-Kinder sitzen oben (Offset 23 px), all-of-Zwischenknoten + sind selbst vertikal zentriert (50 %). Ohne diese Unterscheidung liefen die + Linien zu zentrierten Zwischenknoten ins Leere. Die Enden der Sammelleiste + folgen demselben Abzweigpunkt des jeweiligen Rand-Kindes. */ + .tree.vertical li.has-and>ul.and>li:first-child::after{top:23px;height:calc(100% - 23px)} + .tree.vertical li.has-and>ul.and>li:only-child::after{border:0} + .tree.vertical li.has-and>ul.and>li.has-and::before{top:50%} + .tree.vertical li.has-and>ul.and>li.has-and:first-child::after{top:50%;height:50%} + .tree.vertical li.has-and>ul.and>li.has-and:last-child::after{top:0;height:50%} + .tree.vertical li.has-and>ul.and>li.has-and:only-child::after{border:0} + + /* Mehr Luft zwischen gestapelten Knoten (transponierte Modi): sonst + überlappt das Größen-Badge (oben rechts) eines Knotens mit den + Namens-Tags (unten rechts) des Knotens darüber. Nur zusätzlicher Abstand + NACH unten, damit der obere 23-px-Abzweig unverändert bleibt. */ + .tree.kompakt ul.and>li, + .tree.kompakt ul.or>li, + .tree.vertical ul.or>li, + .tree.vertical ul.and>li:not(.has-and){ + padding-bottom:20px; + } + /* Zentrierte all-of-Zwischenknoten (nur vertikal) brauchen den Abstand + symmetrisch, damit ihr 50%-Abzweig auf der Knotenmitte bleibt. */ + .tree.vertical li.has-and>ul.and>li.has-and{ + padding-top:16px;padding-bottom:16px; + } + /* Vertikal + any-of-Alternative, die selbst all-of zerlegt (z. B. „Web+Nativ“): + Der Knoten sitzt vertikal mittig zu seinem Rechts-Fächer, also muss auch der + EINKOMMENDE any-of-Abzweig auf die Knotenmitte (50 %) zeigen statt auf 23 px — + sonst ist der Knoten von seiner Linie von oben abgetrennt. Symmetrisches + Padding hält die Mitte bei 50 % (überschreibt das asymmetrische ul.or>li). */ + .tree.vertical ul.or>li.has-and{padding-top:16px;padding-bottom:16px} + .tree.vertical ul.or>li.has-and::before{top:50%} + .tree.vertical ul.or>li.has-and:last-child::after{height:50%} + + /* Kompakt: auch All-of läuft nach unten (spart Breite) — kein Rechts-Fächer. + Die graue, gestrichelte Any-of-Führung kommt aus der Basis und gilt hier + wie in allen Modi. Gate-Codierung: und = durchgezogen dunkel (Tinte), + oder = gestrichelt grau. */ + + /* ---------- Kleiner Bildschirm (Smartphone) ---------- + body.mobile wird per matchMedia (<=640px) gesetzt. Standardmäßig ist ein + Bereich groß, der andere schrumpft auf seine Titelzeile und dient als + Umschalter (Tippen bringt ihn nach vorn). Die Min/Normal/Max-Buttons + entfallen; geteilt wird weiterhin über den **Splitter**, der sichtbar + bleibt. Das gestapelte Layout inkl. Gutter liefert bereits die + 820-px-Regel weiter oben. Die Legende bekommt einen eigenen Umschalter + statt der dauerhaften „AGENDA“-Zeile. */ + body.mobile .winbtns{display:none} + /* Mobil: freie Aufteilung per Splitter — beide Titelzeilen bleiben IMMER + stehen (Zeilen-Minimum --pmin ≈ Titelzeilenhöhe). Kein diskretes + Minimieren/Snap; ein zusammengeschobenes Panel schrumpft nur bis auf seine + volle Titelzeile (Körper flext auf 0, Panel ist overflow:hidden). Gilt auch + für den Seitenmodus, der auf Mobil ohnehin gestapelt ist. Der Drag setzt + --drow (JS klemmt), das Tippen auf eine Titelzeile maximiert das Panel. */ + body.mobile .app, + body.mobile .app.side{ + grid-template-columns:minmax(0,1fr); + grid-template-rows:minmax(var(--pmin-d,49px),var(--drow,60%)) 14px minmax(var(--pmin-e,44px),1fr); + grid-template-areas:"diagram" "gutter" "editor"; + } + /* Untertitel: Kurzfassung, garantiert einzeilig (Ellipsis als Sicherung) */ + body.mobile .sub-full{display:none} + body.mobile .sub-short{display:inline} + body.mobile header p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + /* Editor immer gestapelt (Textarea oben, Legende darunter) */ + body.mobile .editor-body{flex-direction:column;align-items:stretch} + body.mobile .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)} + body.mobile .agenda[open]{width:auto} + /* Legende: keine dauerhafte Summary-Zeile, dafür der #legendBtn im Kopf */ + body.mobile .agenda>summary{display:none} + body.mobile .legendbtn{display:inline-flex} + /* Sprachwahl schlank: eingeklappt nur die aktive Sprache. Ein Tipp klappt die + volle Leiste als Overlay über die Kopfzeile auf (verdeckt fsToggle usw., + statt die Zeile zu verbreitern); nach der Auswahl klappt sie wieder ein + (JS). Das „…“ entfällt hier — die aktive Sprache ist selbst der Auslöser. */ + body.mobile .header-tools{position:relative} + body.mobile .langsel button[data-lang]{display:none} + body.mobile .langsel button[data-lang].active{display:inline-block} + body.mobile .langmore{display:none} + body.mobile .langsel.expanded{ + position:absolute;top:0;right:0;z-index:40; + background:var(--card); + box-shadow:0 6px 18px rgba(36,52,71,.28); + } + body.mobile .langsel.expanded button[data-lang]{display:inline-block} + /* Modus-Wähler kompakt: nur das aktive Icon (ein Drittel Platz), Tippen + schaltet reihum weiter (horizontal → kompakt → vertikal → …, siehe JS). */ + body.mobile .seg label{display:none} + body.mobile .seg label:has(input:checked){display:flex} + /* Download schlank: nur der Trigger-Icon-Button; ein Tipp klappt SVG/PNG als + Dropdown-Overlay unter der Kopfzeile auf (verhindert den Zeilenumbruch durch + die breiten „SVG“/„PNG“-Labels). Kopieren bleibt sichtbar. */ + body.mobile .dlgroup{position:relative} + body.mobile .dltrigger{display:inline-flex} + body.mobile .dltrigger[aria-expanded="true"]{border-color:var(--or);color:var(--or)} + body.mobile .dlmenu{display:none} + body.mobile .dlgroup.open .dlmenu{ + display:flex;flex-direction:column;gap:5px; + position:absolute;top:calc(100% + 6px);right:0;z-index:40; + background:var(--card);border:1px solid rgba(36,52,71,.15); + border-radius:8px;padding:5px;box-shadow:0 6px 18px rgba(36,52,71,.28); + } diff --git a/frontend/tests/.gitkeep b/frontend/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..cfe1a4a --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vitest/config'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +// Bettet das Favicon (../docs/brand/favicon.svg, außerhalb des Roots) als +// data:-URI direkt in ein, damit die gebaute Datei wirklich +// EINE self-contained Datei ist (kein Sibling-Asset). Läuft vor Vites eigener +// HTML-Asset-Auflösung ('pre'), damit Vite die data:-URI unangetastet lässt. +function inlineFavicon() { + return { + name: 'werkbaum-inline-favicon', + transformIndexHtml: { + order: 'pre', + handler(html) { + const svg = readFileSync(new URL('../docs/brand/favicon.svg', import.meta.url), 'utf8'); + const uri = 'data:image/svg+xml,' + encodeURIComponent(svg.trim()); + return html.replace(/href="[^"]*favicon\.svg"/, `href="${uri}"`); + }, + }, + }; +} + +// Werkbaum bleibt bewusst eine einzelne, self-contained Datei (file://-tauglich, +// D16). Vite dient nur als Bündler/Testrunner (D19): `vite build` inlint alle +// Module + CSS in dist/index.html; im Dev-Server (`vite`) werden sie einzeln +// geladen. `fs.allow: ['..']` lässt den Dev-Server das Favicon aus +// ../docs/brand/ ausliefern (liegt außerhalb des Projekt-Roots frontend/). +export default defineConfig({ + root: '.', + plugins: [inlineFavicon(), viteSingleFile()], + server: { port: 8137, strictPort: true, fs: { allow: ['..'] } }, + build: { + // Alles inlinen -> keine externen Assets, eine Datei. + assetsInlineLimit: 100_000_000, + cssCodeSplit: false, + }, + test: { + environment: 'node', + include: ['tests/**/*.test.js'], + passWithNoTests: true, + }, +});