diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 807e80c..ccbe7f6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -100,6 +100,41 @@ jobs:
- run: npm run typecheck
- run: npx vite build
+ website:
+ # The same build the Pages workflow deploys, run here so a pull request
+ # cannot rot it: the wasm target, the packed examples, the typecheck and
+ # the bundle.
+ name: Website
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: wasm-pack
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: apps/web/package-lock.json
+
+ - name: The runtime holds to its promises on the wasm target too
+ run: cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets
+
+ - name: Pack the example stacks as single files
+ run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples
+
+ - name: Build the site
+ working-directory: apps/web
+ run: |
+ npm ci
+ npm run wasm
+ npm run format:check
+ npm run build
+
desktop:
name: Desktop shell (${{ matrix.os }})
runs-on: ${{ matrix.os }}
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..66b9016
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,71 @@
+name: Website
+
+# Builds the site in apps/web — the retro landing page, and the whole
+# application compiled to WebAssembly — and deploys it to GitHub Pages.
+#
+# One-time setup: the repository's Settings ▸ Pages ▸ Source must say
+# "GitHub Actions", or the deploy step has nowhere to put the artifact.
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# One deployment at a time, and a new push makes the queued one irrelevant —
+# but a deployment already running is left to finish.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build the site
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - uses: Swatinem/rust-cache@v2
+ # A prebuilt binary, for the same reason cargo-audit is one in ci.yml.
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: wasm-pack
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: apps/web/package-lock.json
+
+ - name: Pack the example stacks as single files
+ run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples
+
+ - name: Build the WebAssembly runtime
+ working-directory: apps/web
+ run: |
+ npm ci
+ npm run wasm
+
+ - name: Build the site
+ working-directory: apps/web
+ run: npm run build
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: apps/web/dist
+
+ deploy:
+ name: Deploy to GitHub Pages
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 0e8be1a..d941b4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,8 @@ dist/
# Tauri's build output, which lives inside the shell crate
apps/desktop/src-tauri/target/
+
+# The website's generated assets: the WebAssembly package wasm-pack writes,
+# and the example stacks packed as single files. Both are built in CI.
+apps/web/wasm-pkg/
+apps/web/public/examples/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf79d18..e9e4df0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,39 @@ does, the entry says so and says what to do about it.
Each entry is written for the person deciding whether to upgrade, so it
describes what is now possible rather than which files moved.
+## Unreleased
+
+HyperLab now runs in a browser, whole:
+**[jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/)**.
+
+- **The website.** A retro landing page with a short history of HyperCard,
+ and a playground that is the full application — the same renderer the
+ desktop draws, over the same runtime compiled to WebAssembly. The six
+ examples are one click away, scripts block on real dialogs, and the map,
+ the message box, the inspector and undo all work as they do at home.
+- **A stack still gets out.** Everything the desktop exports the browser
+ exports: the cards as a PDF, the map as a PNG, the whole stack as a web
+ page, and as a Decker deck. Words drawn inside artwork keep their
+ typeface, which a page has to be handed because it cannot read the
+ machine's own.
+- **Files travel.** The playground opens and downloads stacks as the
+ single-file JSON the desktop already reads and writes, so a stack moves
+ between the two by moving one file.
+- **Bring your own model, or none.** AI ▸ Settings in the browser takes
+ Anthropic, OpenAI, OpenRouter, anything OpenAI-compatible, or the built-in
+ mock. A key is kept in the browser's own storage, is never readable back,
+ and travels only to the provider it belongs to — the site is static files,
+ with no server of its own.
+- The Todo example's list now ends with a return, so the first item anyone
+ adds lands on its own line instead of gluing itself to the last one.
+
+For integrators: `hyperlab-persistence` gained in-memory single-file
+functions (`single_file_string`, `stack_from_single_file`);
+`hyperlab-ai-providers` gained a default-on `native` feature — without it
+the crate is only the wire protocol, for hosts that bring their own
+transport; and `hyperlab_stack::set_clock` lets a host name the clock on
+platforms where the standard one traps, which WebAssembly in a browser is.
+
## 0.2.0 — 2026-08-01
A stack that cannot leave HyperLab is a stack you cannot show anyone. This
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3b272ba..e6a33c0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,11 +10,16 @@ whoever is reading the code in thirty years.
cargo test # the core crates: no GUI toolchain needed
cd apps/desktop && npm install
npm run tauri dev # the application
+
+cd apps/web && npm install
+npm run prepare:assets && npm run dev # the website — see docs/website.md
```
The core crates deliberately do not depend on Tauri, so `cargo test` at the
repository root works on any machine. If you are only touching the runtime,
-the parser or persistence, you never need the desktop toolchain.
+the parser or persistence, you never need the desktop toolchain. The website
+needs Rust's `wasm32-unknown-unknown` target and
+[wasm-pack](https://rustwasm.github.io/wasm-pack/), and nothing else.
## Before you open a pull request
@@ -22,12 +27,26 @@ the parser or persistence, you never need the desktop toolchain.
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
+RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
cd apps/desktop
npm run typecheck && npm run lint && npm run format:check
```
-CI runs exactly these, so if they pass locally they will pass there.
+And if you touched anything the website builds on — the core crates, the
+desktop's components, or `apps/web` itself:
+
+```sh
+cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets
+
+cd apps/web
+npm run prepare:assets # the wasm package and the example stacks
+npm run format:check && npm run build
+```
+
+CI runs exactly these, so if they pass locally they will pass there. Do not
+skip the documentation build: parts of `hyperlab-web` are compiled only for
+wasm, and a doc link to one of them resolves everywhere except there.
## What good looks like here
diff --git a/Cargo.lock b/Cargo.lock
index 23e4c8c..e4fdcf5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -127,6 +127,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -562,6 +572,29 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "hyperlab-web"
+version = "0.2.0"
+dependencies = [
+ "console_error_panic_hook",
+ "hyperlab-ai",
+ "hyperlab-ai-providers",
+ "hyperlab-assistant",
+ "hyperlab-decker",
+ "hyperlab-export",
+ "hyperlab-graph",
+ "hyperlab-hyperscript",
+ "hyperlab-mcp",
+ "hyperlab-persistence",
+ "hyperlab-runtime",
+ "hyperlab-stack",
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
diff --git a/README.md b/README.md
index 4cd4851..0f4414a 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ Cards, stacks, fields, buttons, scripts and message passing are all still
here, and a stack stays inspectable and editable while it runs — the two
qualities that made the original worth learning in the first place.
+🌐 **Try it right now, in your browser:**
+**[jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/)** — the
+full application compiled to WebAssembly, with the six examples one click
+away. Nothing to install, and nothing you make there leaves your machine
+unless you ask a model about it.
+
🕯️ **Bill Atkinson (1951–2025)** wrote QuickDraw, MacPaint and HyperCard, and
insisted the last of them ship free with every Mac. Millions of people built
something with it, and a great many of them never thought of themselves as
@@ -67,6 +73,12 @@ card back](docs/demo.gif)
🚀 Getting Started
+🌐 **The website** — [jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/)
+is the whole application running in your browser, plus a short history of the
+idea it comes from. A stack you download there opens in the desktop
+application, and the other way round: it is the same file. How the site works
+is written down in [`docs/website.md`](docs/website.md).
+
🖥️ **The application** — a `.dmg`, an `.msi`, and a `.deb`, `.rpm` or
`.AppImage`, on the
[releases page](https://github.com/JGalego/HyperLab/releases). Nothing phones
@@ -194,8 +206,12 @@ crates/
_hyperscript — where it goes next
decker/ a stack as a Decker
deck — where else it goes
+ web/ the same commands as
+ the shell, but as
+ WebAssembly — how it runs in a browser
apps/desktop/ the Tauri shell and the React renderer
+apps/web/ the website: the same renderer over crates/web
docs/ architecture, roadmap, HyperTalk reference
examples/ six stacks, which are also tests
```
diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc
new file mode 100644
index 0000000..4ba8ea1
--- /dev/null
+++ b/apps/web/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "singleQuote": true,
+ "printWidth": 90,
+ "trailingComma": "all"
+}
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..660456e
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,228 @@
+
+
+
+
+
+ HyperLab — an open-source, AI-native successor to HyperCard
+
+
+
+
+
+
+
+
+ ⌘
+ HyperLab
+ File
+ Edit
+ Go
+ AI
+
+ GitHub
+
+
+
+
+
+
+
+
Welcome to HyperLab
+
+
+
+ An open-source, AI-native successor to HyperCard. Cards,
+ buttons, fields and HyperTalk, and a stack stays editable while it runs.
+
+
+on mouseUp
+ put ai("Summarize this card") into field "Summary"
+end mouseUp
+ It came free on every Mac for over a decade. Then it was gone, and nothing
+ quite took its place.
+
+
+ HyperLab picks the idea back up, with language models part of HyperTalk
+ itself.
+
+
+ 🕯️ Dedicated to Bill Atkinson (1951–2025), who wrote
+ QuickDraw, MacPaint and HyperCard, and insisted the last of them ship free
+ with every Mac.
+
+
+
+
+
+
+
+
+
Examples
+
+
+
+ Each opens in the playground, running. Switch to the edit tool to read the
+ scripts that make it work.
+
+ HyperLab works with no provider configured.
+ AI ▸ Settings takes Anthropic, OpenAI, OpenRouter, a local
+ Ollama, or a mock that answers offline.
+
+
+ Your API key stays in your browser. The site is static files;
+ requests go from your browser directly to your provider, and no command reads
+ a saved key back out. The sidebar shows the exact text of everything sent.
+
+
+ The assistant works through the same commands you do, so its changes are
+ undoable like any other.
+
+
+
+
+
+
+
+
+
The desktop application
+
+
+
+ A .dmg, an .msi, and a .deb,
+ .rpm or .AppImage on the
+ releases page. A stack downloaded from the playground opens in the application; it is the
+ same file.
+
+
+ Stacks export as a PDF, as a PNG of the map, as a self-contained web page, and
+ as a Decker deck — from the
+ playground as readily as from the application.
+
+
+
+
+
+
+
+
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
new file mode 100644
index 0000000..743a528
--- /dev/null
+++ b/apps/web/package-lock.json
@@ -0,0 +1,1033 @@
+{
+ "name": "hyperlab-web",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hyperlab-web",
+ "version": "0.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tauri-apps/api": "^2.1.1",
+ "@tauri-apps/plugin-dialog": "^2.2.0",
+ "@types/node": "^24.0.0",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^6.0.5",
+ "prettier": "^3.4.2",
+ "typescript": "^6.0.3",
+ "vite": "^8.2.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "2.0.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+ "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+ "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+ "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+ "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+ "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+ "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+ "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+ "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+ "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+ "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+ "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+ "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+ "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+ "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "2.0.0-alpha.3",
+ "@emnapi/runtime": "2.0.0-alpha.3",
+ "@napi-rs/wasm-runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+ "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+ "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+ "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/@tauri-apps/api": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
+ "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-dialog": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
+ "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
+ "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/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/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/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/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/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/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/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.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
+ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.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.2.1",
+ "@rolldown/binding-darwin-arm64": "1.2.1",
+ "@rolldown/binding-darwin-x64": "1.2.1",
+ "@rolldown/binding-freebsd-x64": "1.2.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+ "@rolldown/binding-linux-arm64-musl": "1.2.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-musl": "1.2.1",
+ "@rolldown/binding-openharmony-arm64": "1.2.1",
+ "@rolldown/binding-wasm32-wasi": "1.2.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+ "@rolldown/binding-win32-x64-msvc": "1.2.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "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/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/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/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "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.4.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
+ }
+ }
+ }
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..c3d24b9
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "hyperlab-web",
+ "private": true,
+ "version": "0.2.0",
+ "type": "module",
+ "description": "The HyperLab website: a retro landing page and the full application in the browser.",
+ "license": "MIT",
+ "scripts": {
+ "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack",
+ "examples": "cargo run -p hyperlab-persistence --example pack_single_files -- public/examples",
+ "prepare:assets": "npm run wasm && npm run examples",
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tauri-apps/api": "^2.1.1",
+ "@tauri-apps/plugin-dialog": "^2.2.0",
+ "@types/node": "^24.0.0",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^6.0.5",
+ "prettier": "^3.4.2",
+ "typescript": "^6.0.3",
+ "vite": "^8.2.0"
+ }
+}
diff --git a/apps/web/play.html b/apps/web/play.html
new file mode 100644
index 0000000..4b1e292
--- /dev/null
+++ b/apps/web/play.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ HyperLab
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/coi-serviceworker.js b/apps/web/public/coi-serviceworker.js
new file mode 100644
index 0000000..9901474
--- /dev/null
+++ b/apps/web/public/coi-serviceworker.js
@@ -0,0 +1,146 @@
+/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
+let coepCredentialless = false;
+if (typeof window === 'undefined') {
+ self.addEventListener("install", () => self.skipWaiting());
+ self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
+
+ self.addEventListener("message", (ev) => {
+ if (!ev.data) {
+ return;
+ } else if (ev.data.type === "deregister") {
+ self.registration
+ .unregister()
+ .then(() => {
+ return self.clients.matchAll();
+ })
+ .then(clients => {
+ clients.forEach((client) => client.navigate(client.url));
+ });
+ } else if (ev.data.type === "coepCredentialless") {
+ coepCredentialless = ev.data.value;
+ }
+ });
+
+ self.addEventListener("fetch", function (event) {
+ const r = event.request;
+ if (r.cache === "only-if-cached" && r.mode !== "same-origin") {
+ return;
+ }
+
+ const request = (coepCredentialless && r.mode === "no-cors")
+ ? new Request(r, {
+ credentials: "omit",
+ })
+ : r;
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ if (response.status === 0) {
+ return response;
+ }
+
+ const newHeaders = new Headers(response.headers);
+ newHeaders.set("Cross-Origin-Embedder-Policy",
+ coepCredentialless ? "credentialless" : "require-corp"
+ );
+ if (!coepCredentialless) {
+ newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin");
+ }
+ newHeaders.set("Cross-Origin-Opener-Policy", "same-origin");
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: newHeaders,
+ });
+ })
+ .catch((e) => console.error(e))
+ );
+ });
+
+} else {
+ (() => {
+ const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf");
+ window.sessionStorage.removeItem("coiReloadedBySelf");
+ const coepDegrading = (reloadedBySelf == "coepdegrade");
+
+ // You can customize the behavior of this script through a global `coi` variable.
+ const coi = {
+ shouldRegister: () => !reloadedBySelf,
+ shouldDeregister: () => false,
+ coepCredentialless: () => true,
+ coepDegrade: () => true,
+ doReload: () => window.location.reload(),
+ quiet: false,
+ ...window.coi
+ };
+
+ const n = navigator;
+ const controlling = n.serviceWorker && n.serviceWorker.controller;
+
+ // Record the failure if the page is served by serviceWorker.
+ if (controlling && !window.crossOriginIsolated) {
+ window.sessionStorage.setItem("coiCoepHasFailed", "true");
+ }
+ const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed");
+
+ if (controlling) {
+ // Reload only on the first failure.
+ const reloadToDegrade = coi.coepDegrade() && !(
+ coepDegrading || window.crossOriginIsolated
+ );
+ n.serviceWorker.controller.postMessage({
+ type: "coepCredentialless",
+ value: (reloadToDegrade || coepHasFailed && coi.coepDegrade())
+ ? false
+ : coi.coepCredentialless(),
+ });
+ if (reloadToDegrade) {
+ !coi.quiet && console.log("Reloading page to degrade COEP.");
+ window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade");
+ coi.doReload("coepdegrade");
+ }
+
+ if (coi.shouldDeregister()) {
+ n.serviceWorker.controller.postMessage({ type: "deregister" });
+ }
+ }
+
+ // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are
+ // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here.
+ if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return;
+
+ if (!window.isSecureContext) {
+ !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required.");
+ return;
+ }
+
+ // In some environments (e.g. Firefox private mode) this won't be available
+ if (!n.serviceWorker) {
+ !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode.");
+ return;
+ }
+
+ n.serviceWorker.register(window.document.currentScript.src).then(
+ (registration) => {
+ !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope);
+
+ registration.addEventListener("updatefound", () => {
+ !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker.");
+ window.sessionStorage.setItem("coiReloadedBySelf", "updatefound");
+ coi.doReload();
+ });
+
+ // If the registration is active, but it's not controlling the page
+ if (registration.active && !n.serviceWorker.controller) {
+ !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker.");
+ window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling");
+ coi.doReload();
+ }
+ },
+ (err) => {
+ !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err);
+ }
+ );
+ })();
+}
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
new file mode 100644
index 0000000..4a888dd
--- /dev/null
+++ b/apps/web/public/favicon.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/web/public/fonts/LICENSE.txt b/apps/web/public/fonts/LICENSE.txt
new file mode 100644
index 0000000..7a21f3c
--- /dev/null
+++ b/apps/web/public/fonts/LICENSE.txt
@@ -0,0 +1,102 @@
+Liberation Sans is used by HyperLab's website to draw the words inside a
+stack's artwork when a PDF or a Decker deck is exported. A browser cannot
+read the machine's own fonts, so this one is fetched — only when you
+actually export something — and handed to the converter.
+
+Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.
+Digitized data copyright (c) 2010 Google Corporation with Reserved Font
+Names Arimo, Tinos and Cousine.
+
+Licensed under the SIL Open Font License, Version 1.1, reproduced below.
+Upstream: https://github.com/liberationfonts
+
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+
+PREAMBLE The goals of the Open Font License (OFL) are to stimulate
+worldwide development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to provide
+a free and open framework in which fonts may be shared and improved in
+partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves.
+The fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such.
+This may include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components
+as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting ? in part or in whole ?
+any of the components of the Original Version, by changing formats or
+by porting the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical writer
+or other person who contributed to the Font Software.
+
+
+PERMISSION & CONDITIONS
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,in
+ Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+ redistributed and/or sold with any software, provided that each copy
+ contains the above copyright notice and this license. These can be
+ included either as stand-alone text files, human-readable headers or
+ in the appropriate machine-readable metadata fields within text or
+ binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+ Name(s) unless explicit written permission is granted by the
+ corresponding Copyright Holder. This restriction only applies to the
+ primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+ Software shall not be used to promote, endorse or advertise any
+ Modified Version, except to acknowledge the contribution(s) of the
+ Copyright Holder(s) and the Author(s) or with their explicit written
+ permission.
+
+5) The Font Software, modified or unmodified, in part or in whole, must
+ be distributed entirely under this license, and must not be distributed
+ under any other license. The requirement for fonts to remain under
+ this license does not apply to any document created using the Font
+ Software.
+
+
+
+TERMINATION
+This license becomes null and void if any of the above conditions are not met.
+
+
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
+DEALINGS IN THE FONT SOFTWARE.
diff --git a/apps/web/public/fonts/LiberationSans-Regular.ttf b/apps/web/public/fonts/LiberationSans-Regular.ttf
new file mode 100644
index 0000000..7769c41
Binary files /dev/null and b/apps/web/public/fonts/LiberationSans-Regular.ttf differ
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..3e7f569
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,605 @@
+/**
+ * The application, as a page.
+ *
+ * A sibling of the desktop's `App.tsx` that renders the same components —
+ * the card, the inspector, the assistant, the map — over the WebAssembly
+ * runtime. What is different is exactly what a browser does differently:
+ * files are uploads and downloads, pictures arrive from a file picker, and
+ * the example stacks are fetched from the site itself.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { Assistant } from '../../desktop/src/components/Assistant';
+import { Card } from '../../desktop/src/components/Card';
+import { Dialog } from '../../desktop/src/components/Dialog';
+import { Inspector } from '../../desktop/src/components/Inspector';
+import { MenuBar, type MenuEntry } from '../../desktop/src/components/MenuBar';
+import { StackMap } from '../../desktop/src/components/StackMap';
+import { StatusBar } from '../../desktop/src/components/StatusBar';
+import { toPng } from '../../desktop/src/map/picture';
+import type {
+ AiView,
+ DialogRequest,
+ Graph,
+ Outcome,
+ PartView,
+ Selection,
+ StackView,
+ Tool,
+} from '../../desktop/src/types';
+
+import * as api from './api';
+import { AiSettings } from './components/AiSettings';
+
+/**
+ * The stacks bundled with the site, packed from `examples/` by
+ * `cargo run -p hyperlab-persistence --example pack_single_files`.
+ */
+const EXAMPLES = [
+ 'Address Book',
+ 'Cluedo',
+ 'LLMs for n00bs',
+ 'Myst',
+ 'Recipe Box',
+ 'Todo',
+] as const;
+
+/**
+ * The typeface handed to the exporters, fetched the first time one runs.
+ *
+ * A page cannot read the machine's fonts, so words drawn inside a stack's
+ * artwork would come out of a PDF or a deck missing. This is fetched only
+ * when someone actually exports, so the 400 KB never lands on an ordinary
+ * visit. A failure is not fatal: the export goes ahead without the labels,
+ * which is what the desktop does on a machine with no fonts either.
+ */
+let fontRegistered: Promise | null = null;
+
+function withFont(): Promise {
+ fontRegistered ??= fetch('fonts/LiberationSans-Regular.ttf')
+ .then((response) => {
+ if (!response.ok) throw new Error(`${response.status}`);
+ return response.arrayBuffer();
+ })
+ .then((buffer) => api.addFont(new Uint8Array(buffer)))
+ .catch(() => {
+ // Tried once; a second export should try again rather than inherit
+ // this failure for the life of the page.
+ fontRegistered = null;
+ });
+ return fontRegistered;
+}
+
+/** Hands the user a file, which is what "save" means on a page. */
+function download(name: string, text: string, type: string) {
+ const url = URL.createObjectURL(new Blob([text], { type }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+function downloadBytes(name: string, bytes: Uint8Array, type: string) {
+ const url = URL.createObjectURL(new Blob([bytes as unknown as BlobPart], { type }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * What to add to a notice about a translation that lost something.
+ *
+ * A destination that came across whole and one that dropped a handler both
+ * wrote a file, and the difference is worth saying out loud.
+ */
+function leftBehind(notes: string[]): string {
+ if (notes.length === 0) return '';
+ const each = notes.length === 1 ? 'thing' : 'things';
+ return ` — ${notes.length} ${each} had no equivalent: ${[...new Set(notes)].join('; ')}`;
+}
+
+export function App() {
+ const [view, setView] = useState(api.emptyView);
+ const [selection, setSelection] = useState(null);
+ const [tool, setTool] = useState('browse');
+ const [error, setError] = useState(null);
+ // Kept apart from `error` so that "downloaded" is not dressed as a failure.
+ const [notice, setNotice] = useState(null);
+ const [dialog, setDialog] = useState(null);
+ const [ready, setReady] = useState(false);
+ const [ai, setAi] = useState(null);
+ const [aiOpen, setAiOpen] = useState(false);
+ const [aiSettings, setAiSettings] = useState(false);
+ const [map, setMap] = useState(null);
+ // Pictures are fetched by name and kept, because a snapshot arrives after
+ // every command and re-encoding a card of artwork each time would be
+ // absurd. Cleared when a different stack is opened.
+ const [pictures, setPictures] = useState(new Map());
+
+ // The two file pickers, hidden until a menu item clicks them.
+ const stackPicker = useRef(null);
+ const picturePicker = useRef(null);
+ const pictureLayer = useRef<'card' | 'background'>('card');
+
+ const apply = useCallback((outcome: Outcome) => {
+ setView(outcome.view);
+ setError(null);
+ }, []);
+
+ /** Runs a command, showing anything that went wrong rather than throwing. */
+ const run = useCallback(
+ (work: () => Promise) => {
+ work().then(apply, (reason: unknown) => setError(String(reason)));
+ },
+ [apply],
+ );
+
+ const openExample = useCallback(
+ (name: string) => {
+ setPictures(new Map());
+ run(() =>
+ fetch(`examples/${encodeURIComponent(name)}.hl.json`)
+ .then((response) => {
+ if (!response.ok) throw new Error(`could not fetch "${name}"`);
+ return response.text();
+ })
+ .then((text) => api.openStackText(text)),
+ );
+ },
+ [run],
+ );
+
+ // Watching for dialogs comes first: a stack whose openStack handler asks a
+ // question would otherwise block with nothing listening.
+ useEffect(() => {
+ let stop: (() => void) | undefined;
+ let cancelled = false;
+ api.onDialog(setDialog).then(
+ (unlisten) => {
+ if (cancelled) unlisten();
+ else stop = unlisten;
+ },
+ () => {},
+ );
+ return () => {
+ cancelled = true;
+ stop?.();
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ api
+ .ready()
+ .then(() => Promise.all([api.aiView().catch(() => null), api.getView()]))
+ .then(
+ ([aiLoaded, outcome]) => {
+ if (cancelled) return;
+ setAi(aiLoaded);
+ apply(outcome);
+ setReady(true);
+ // A link from the landing page may name a stack to start in.
+ const wanted = new URLSearchParams(window.location.search).get('stack');
+ if (wanted !== null && (EXAMPLES as readonly string[]).includes(wanted)) {
+ openExample(wanted);
+ }
+ void api.backendMode().then((mode) => {
+ if (mode === 'fallback' && !cancelled) {
+ setNotice(
+ 'Running without cross-origin isolation; script dialogs use the browser’s plain prompt.',
+ );
+ }
+ });
+ },
+ (reason: unknown) => {
+ if (cancelled) return;
+ setError(String(reason));
+ setReady(true);
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [apply, openExample]);
+
+ // Whatever the current card draws, fetched once.
+ useEffect(() => {
+ const wanted = [...(view.background?.parts ?? []), ...view.card.parts]
+ .map((part) => part.source)
+ .filter((source) => source !== '' && !pictures.has(source));
+ if (wanted.length === 0) return;
+
+ let cancelled = false;
+ Promise.all(
+ [...new Set(wanted)].map((source) =>
+ api.stackImage(source).then(
+ (uri) => [source, uri] as const,
+ () => null,
+ ),
+ ),
+ ).then((fetched) => {
+ const found = fetched.filter((one) => one !== null);
+ if (cancelled || found.length === 0) return;
+ setPictures((known) => new Map([...known, ...found]));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [view.card, view.background, pictures]);
+
+ // Keyboard shortcuts, kept in one place so the menus and the keys cannot
+ // drift apart: both call the same functions.
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (!(event.metaKey || event.ctrlKey)) return;
+ const key = event.key.toLowerCase();
+ const shortcuts: Record void> = {
+ s: () => downloadStack(),
+ o: () => stackPicker.current?.click(),
+ n: () => run(() => api.newCard()),
+ z: () => run(() => (event.shiftKey ? api.redo() : api.undo())),
+ };
+ const action = shortcuts[key];
+ if (action) {
+ event.preventDefault();
+ action();
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [run]);
+
+ function downloadStack() {
+ api
+ .saveStackText()
+ .then(({ name, text }) => {
+ download(`${name}.hl.json`, text, 'application/json');
+ setNotice(`Downloaded ${name}.hl.json — it also opens in the desktop app`);
+ return api.getView();
+ })
+ .then(apply, (reason: unknown) => setError(String(reason)));
+ }
+
+ function openPickedStack(files: FileList | null) {
+ const file = files?.[0];
+ if (file === undefined) return;
+ setPictures(new Map());
+ run(() => file.text().then((text) => api.openStackText(text)));
+ }
+
+ function importPickedPicture(files: FileList | null) {
+ const file = files?.[0];
+ if (file === undefined) return;
+ run(() =>
+ file
+ .arrayBuffer()
+ .then((buffer) =>
+ api.importImageBytes(file.name, pictureLayer.current, new Uint8Array(buffer)),
+ ),
+ );
+ }
+
+ /** The stack as one HTML file, which a browser can hand straight over. */
+ function exportWebPage() {
+ api.exportWebPage().then(
+ ({ source, notes }) => {
+ download(`${view.stackName}.html`, source, 'text/html');
+ setNotice(`Downloaded ${view.stackName}.html${leftBehind(notes)}`);
+ },
+ (reason: unknown) => setError(String(reason)),
+ );
+ }
+
+ /** The whole stack as a PDF, one page per card. */
+ function exportPdf() {
+ withFont()
+ .then(() => api.exportPdf())
+ .then(
+ (bytes) => {
+ downloadBytes(`${view.stackName}.pdf`, bytes, 'application/pdf');
+ setNotice(`Downloaded ${view.stackName}.pdf`);
+ },
+ (reason: unknown) => setError(String(reason)),
+ );
+ }
+
+ /** The stack as a Decker deck. */
+ function exportDeck() {
+ withFont()
+ .then(() => api.exportDeck())
+ .then(
+ ({ source, notes }) => {
+ download(`${view.stackName}.deck`, source, 'text/plain');
+ setNotice(`Downloaded ${view.stackName}.deck${leftBehind(notes)}`);
+ },
+ (reason: unknown) => setError(String(reason)),
+ );
+ }
+
+ /** The map as a PNG. Drawn in the window, because only it knows the shape. */
+ function saveMap(svg: SVGSVGElement) {
+ toPng(svg).then(
+ (bytes) => {
+ downloadBytes(`${view.stackName} map.png`, bytes, 'image/png');
+ setNotice(`Downloaded ${view.stackName} map.png`);
+ },
+ (reason: unknown) => setError(String(reason)),
+ );
+ }
+
+ if (!ready) {
+ return (
+