diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f91b25d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+backend
+frontend/node_modules
+frontend/dist
+frontend/.svelte-kit
+**/.DS_Store
+*.md
+!deploy/README.md
+.audit
+docs/archive
diff --git a/deploy/nginx-m1.conf b/deploy/nginx-m1.conf
new file mode 100644
index 0000000..3334c98
--- /dev/null
+++ b/deploy/nginx-m1.conf
@@ -0,0 +1,17 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA: fall back to index.html for any path
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
+ expires 7d;
+ add_header Cache-Control "public";
+ try_files $uri =404;
+ }
+}
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..321b6ac
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,13 @@
+# Build the static app, then serve only the built artifacts.
+# Build context is the repo root: `docker build -f frontend/Dockerfile .`
+FROM node:20-slim AS build
+WORKDIR /app
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm install --no-audit --no-fund
+COPY frontend/ ./
+RUN npm run build
+
+FROM nginx:1.27-alpine
+COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+EXPOSE 80
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..7112963
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ SlabUploader — Capture
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..2210fc1
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1520 @@
+{
+ "name": "slabuploader-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "slabuploader-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "idb-keyval": "^6.3.0",
+ "svelte": "^4.2.20"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "svelte-preprocess": "^5.1.4",
+ "typescript": "^5.7.3",
+ "vite": "^5.4.19"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
+ "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz",
+ "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz",
+ "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz",
+ "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz",
+ "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz",
+ "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz",
+ "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz",
+ "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz",
+ "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz",
+ "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz",
+ "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz",
+ "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz",
+ "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz",
+ "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz",
+ "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz",
+ "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz",
+ "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz",
+ "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz",
+ "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
+ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
+ "dev": true,
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
+ "debug": "^4.3.4",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.10",
+ "svelte-hmr": "^0.16.0",
+ "vitefu": "^0.2.5"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
+ "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "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=="
+ },
+ "node_modules/@types/pug": {
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
+ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
+ "dev": true
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/code-red": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
+ "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.10.0",
+ "estree-walker": "^3.0.3",
+ "periscopic": "^3.1.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/css-tree": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
+ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+ "dependencies": {
+ "mdn-data": "2.0.30",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-indent": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
+ "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es6-promise": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
+ "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": 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,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true
+ },
+ "node_modules/idb-keyval": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
+ "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow=="
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
+ },
+ "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==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.0.30",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
+ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/periscopic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
+ "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^3.0.0",
+ "is-reference": "^3.0.0"
+ }
+ },
+ "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
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "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"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz",
+ "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.2",
+ "@rollup/rollup-android-arm64": "4.63.2",
+ "@rollup/rollup-darwin-arm64": "4.63.2",
+ "@rollup/rollup-darwin-x64": "4.63.2",
+ "@rollup/rollup-freebsd-arm64": "4.63.2",
+ "@rollup/rollup-freebsd-x64": "4.63.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.2",
+ "@rollup/rollup-linux-arm64-musl": "4.63.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.2",
+ "@rollup/rollup-linux-loong64-musl": "4.63.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-musl": "4.63.2",
+ "@rollup/rollup-openbsd-x64": "4.63.2",
+ "@rollup/rollup-openharmony-arm64": "4.63.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.2",
+ "@rollup/rollup-win32-x64-gnu": "4.63.2",
+ "@rollup/rollup-win32-x64-msvc": "4.63.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/sander": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz",
+ "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==",
+ "dev": true,
+ "dependencies": {
+ "es6-promise": "^3.1.2",
+ "graceful-fs": "^4.1.3",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.2"
+ }
+ },
+ "node_modules/sorcery": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz",
+ "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.14",
+ "buffer-crc32": "^1.0.0",
+ "minimist": "^1.2.0",
+ "sander": "^0.5.0"
+ },
+ "bin": {
+ "sorcery": "bin/sorcery"
+ }
+ },
+ "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==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "4.2.20",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.9.0",
+ "aria-query": "^5.3.0",
+ "axobject-query": "^4.0.0",
+ "code-red": "^1.0.3",
+ "css-tree": "^2.3.1",
+ "estree-walker": "^3.0.3",
+ "is-reference": "^3.0.1",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.4",
+ "periscopic": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/svelte-hmr": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
+ "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20 || ^14.13.1 || >= 16"
+ },
+ "peerDependencies": {
+ "svelte": "^3.19.0 || ^4.0.0"
+ }
+ },
+ "node_modules/svelte-preprocess": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz",
+ "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "@types/pug": "^2.0.6",
+ "detect-indent": "^6.1.0",
+ "magic-string": "^0.30.5",
+ "sorcery": "^0.11.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.10.2",
+ "coffeescript": "^2.5.1",
+ "less": "^3.11.3 || ^4.0.0",
+ "postcss": "^7 || ^8",
+ "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
+ "pug": "^3.0.0",
+ "sass": "^1.26.8",
+ "stylus": "^0.55.0",
+ "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "coffeescript": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "postcss-load-config": {
+ "optional": true
+ },
+ "pug": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
+ "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "dev": true,
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..1bcb736
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "slabuploader-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "idb-keyval": "^6.3.0",
+ "svelte": "^4.2.20"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "svelte-preprocess": "^5.1.4",
+ "typescript": "^5.7.3",
+ "vite": "^5.4.19"
+ }
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..21f787c
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,94 @@
+
+
+{#if gate}
+
+ Unsupported browser
+
+ SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
+ iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
+
+ Missing: {gate}
+
+{:else}
+
+ {#if cap.k === 'resume'}
+ session.continueDraft()} onFresh={() => void session.startFresh()} />
+ {:else if cap.k === 'addPhoto'}
+
+ {:else if cap.k === 'mask'}
+
+ {:else if cap.k === 'axis'}
+
+ {:else}
+
+ {/if}
+
+{/if}
+
+
diff --git a/frontend/src/browser.ts b/frontend/src/browser.ts
new file mode 100644
index 0000000..b6f73d6
--- /dev/null
+++ b/frontend/src/browser.ts
@@ -0,0 +1,26 @@
+// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
+// Gated on required API presence; anything missing → hard stop.
+export function supported(): string | null {
+ if (typeof window === 'undefined') return 'window unavailable';
+ if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
+ if (typeof Worker === 'undefined') return 'Worker';
+ if (typeof indexedDB === 'undefined') return 'IndexedDB';
+ const hasOffscreen =
+ typeof OffscreenCanvas !== 'undefined' &&
+ typeof OffscreenCanvas.prototype.convertToBlob === 'function';
+ const hasCanvasBlob =
+ typeof HTMLCanvasElement !== 'undefined' &&
+ typeof HTMLCanvasElement.prototype.toBlob === 'function';
+ if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
+ const ua = navigator.userAgent;
+ const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ const isAndroid = /Android/i.test(ua);
+ const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
+ const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
+ const isEdge = /Edg/i.test(ua);
+ const isFirefox = /Firefox|FxiOS/i.test(ua);
+ if (isIOS && isSafari) return null;
+ if (isAndroid && isChrome) return null;
+ if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
+ return 'unsupported browser';
+}
diff --git a/frontend/src/capture/AddPhoto.svelte b/frontend/src/capture/AddPhoto.svelte
new file mode 100644
index 0000000..176a1d8
--- /dev/null
+++ b/frontend/src/capture/AddPhoto.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte
new file mode 100644
index 0000000..a94272f
--- /dev/null
+++ b/frontend/src/capture/AxisPanel.svelte
@@ -0,0 +1,32 @@
+
+
+
+ Length axis
+ The arrow points along the slab length. If it does not follow the slab, flip it.
+
+
+ Back
+ Flip 180°
+ Approve axis
+
+
+
+
diff --git a/frontend/src/capture/MaskPanel.svelte b/frontend/src/capture/MaskPanel.svelte
new file mode 100644
index 0000000..452228b
--- /dev/null
+++ b/frontend/src/capture/MaskPanel.svelte
@@ -0,0 +1,93 @@
+
+
+
+ Mask
+
+
+ {#if busy}
Working…
{/if}
+ {#if state.failed}
Mask pass failed. Adjust and retry, or retake the photo.
{/if}
+ {#if empty}
No slab pixels in the mask. Adjust sensitivity or retake.
{/if}
+
+
+
+
+ {#each sheets as s (s)}
+ setSheet(s)}>
+ {s[0].toUpperCase() + s.slice(1)}
+
+ {/each}
+
+
+ setSensitivity(state.control.sensitivity - 1)}>−
+ setSensitivity(Number(e.currentTarget.value))}
+ />
+ setSensitivity(state.control.sensitivity + 1)}>+
+ {state.control.sensitivity}
+
+
+
+
+ Back
+ Retake
+
+ Approve mask
+
+
+
+
+
diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte
new file mode 100644
index 0000000..a8be483
--- /dev/null
+++ b/frontend/src/capture/MeasurePanel.svelte
@@ -0,0 +1,101 @@
+
+
+
+ Measure
+
+
+
+
+
Length L
+
+ setLength(state.lengthEighths - 1)} aria-label="Length minus">−
+ {fmtEighths(state.lengthEighths)}
+ setLength(state.lengthEighths + 1)} aria-label="Length plus">+
+
+
+
+
Thickness
+
+ setThickness(state.thicknessEighths - 1)} aria-label="Thickness minus">−
+ {fmtEighths(state.thicknessEighths)}
+ setThickness(state.thicknessEighths + 1)} aria-label="Thickness plus">+
+
+
+
+
+
+ Back
+
+ {calcBusy ? 'Calculating…' : 'Calculate'}
+
+
+
+ {#if state.results}
+
+
+
Widths {fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})
+
Sqft {state.results.sqft.toFixed(2)}
+
Bdft {state.results.bdft.toFixed(2)}
+
Scale {state.results.scale.toFixed(2)} px/in
+
+ {#if state.results.sourceBelowFloor}
+
Source is below the 1600px floor for the inventory PNG. Retake a larger photo.
+ {/if}
+ {#if pngUrl && !state.results.sourceBelowFloor}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/frontend/src/capture/PhotoStage.svelte b/frontend/src/capture/PhotoStage.svelte
new file mode 100644
index 0000000..4104181
--- /dev/null
+++ b/frontend/src/capture/PhotoStage.svelte
@@ -0,0 +1,113 @@
+
+
+
+ {#if url}
+
+ {:else}
+
Decoding photo…
+ {/if}
+
+
+
diff --git a/frontend/src/capture/Resume.svelte b/frontend/src/capture/Resume.svelte
new file mode 100644
index 0000000..405de66
--- /dev/null
+++ b/frontend/src/capture/Resume.svelte
@@ -0,0 +1,28 @@
+
+
+
+ Resume?
+ You have an in-progress slab on this device.
+
+ Continue
+ Start fresh
+
+
+
+
diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts
new file mode 100644
index 0000000..caac078
--- /dev/null
+++ b/frontend/src/capture/capture.types.ts
@@ -0,0 +1,131 @@
+// Domain model for the M1 capture flow.
+// The Capture tagged union is the state machine: each branch carries exactly the
+// data its state can hold, so an illegal state is a type error.
+
+// Integer eighths of an inch; divide by 8 only at display.
+export type Eighths = number;
+
+export const inToEighths = (inches: number): Eighths => Math.round(inches * 8);
+export const eighthsToIn = (e: Eighths): number => e / 8;
+
+// Source: all geometry, measurement, and PNG work uses source pixels, never the
+// CSS size of the preview.
+export interface Source {
+ widthPx: number;
+ heightPx: number;
+ blob: Blob;
+}
+
+export type Sheet = 'auto' | 'green' | 'black';
+export interface MaskParams {
+ sheet: Sheet;
+ sensitivity: number; // 0–100, step 1
+}
+
+// Axis-aligned box, source px.
+export interface ABox {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+// Minimum-area bounding rectangle of the mask, source px.
+export interface BBox {
+ corners: [number, number][]; // 4 corners, counter-clockwise
+ longSidePx: number; // longest_axis_px → scale numerator
+ angleDeg: number; // long-side angle, degrees, [0, 180)
+}
+
+// The approved mask is the footprint for axis, measurement, and the inventory PNG.
+export interface Mask {
+ params: MaskParams;
+ slabPx: number; // 0 ⇒ approve blocked, user retakes
+ bitset: Uint8Array; // source-grid mask, 1 byte/px
+ bbox: BBox;
+ slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
+}
+
+export interface Axis {
+ bbox: BBox;
+ heading: 1 | -1; // which way along the long side is forward (180° flip)
+}
+
+export interface Widths {
+ min: Eighths;
+ max: Eighths;
+ avg: Eighths;
+ every6In: Eighths[];
+}
+
+export interface Measures {
+ lengthEighths: Eighths;
+ thicknessEighths: Eighths;
+ scale: number; // px per inch
+ widths: Widths;
+ sqft: number; // 2 dp
+ bdft: number; // 2 dp
+ inventoryPng: Blob;
+ sourceBelowFloor: boolean;
+}
+
+// The machine. 'resume' exists only while a draft exists and has not been chosen.
+export type Capture =
+ | { k: 'resume'; draft: Draft }
+ | { k: 'addPhoto' }
+ | {
+ k: 'mask';
+ source: Source;
+ control: MaskParams; // immediate control value (what the UI shows)
+ overlay: Mask | null; // last settled mask; null until first pass
+ failed: boolean;
+ }
+ | {
+ k: 'axis';
+ source: Source;
+ approvedMask: Mask; // frozen footprint
+ heading: 1 | -1;
+ }
+ | {
+ k: 'measure';
+ source: Source;
+ approvedMask: Mask;
+ axis: Axis; // frozen
+ lengthEighths: Eighths;
+ thicknessEighths: Eighths;
+ results: Measures | null;
+ };
+
+// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
+// and PNG are re-derived from stored data, never stored second-hand.
+export interface Draft {
+ version: 1;
+ stage: 'mask' | 'axis' | 'measure'; // last approved state
+ source: Source;
+ maskParams: MaskParams; // → re-derive mask on resume
+ mask: Omit; // slabPx, bbox, slabBounds
+ heading: 1 | -1; // present when stage >= axis
+ lengthEighths: Eighths;
+ thicknessEighths: Eighths;
+ results: Omit | null; // present when results computed
+ inventoryPng: Blob | null; // stored so Continue can show Measure results
+}
+
+// Worker protocol. Job ids increase; the worker drops anything older than the
+// current id, so a stale result can never blank a newer one.
+export type WorkerIn =
+ | { id: number; kind: 'init'; blob: Blob }
+ | { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number }
+ | { id: number; kind: 'invalidate' };
+
+export type WorkerOut =
+ | { id: number; kind: 'decoded'; w: number; h: number }
+ | {
+ id: number;
+ kind: 'ready';
+ slabPx: number;
+ bitset: Uint8Array;
+ bbox: BBox;
+ slabBounds: ABox;
+ }
+ | { id: number; kind: 'failed'; reason: string };
diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts
new file mode 100644
index 0000000..c9a14c8
--- /dev/null
+++ b/frontend/src/capture/session.ts
@@ -0,0 +1,356 @@
+import { writable } from 'svelte/store';
+import type {
+ Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
+} from './capture.types';
+import { eighthsToIn } from './capture.types';
+import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
+import { measure } from '../measure/measure';
+import { encodeInventoryPng } from '../png/inventory';
+
+const DEBOUNCE_MS = 2000;
+const BUSY_MS = 300;
+
+export interface CaptureSession {
+ capture: import('svelte/store').Readable;
+ busy: import('svelte/store').Readable;
+ resumeProbe(): Promise;
+ continueDraft(): void;
+ startFresh(): void;
+ addPhoto(file: File): Promise;
+ retakes(): void;
+ setSensitivity(n: number): void;
+ setSheet(s: Sheet): void;
+ approveMask(): void;
+ rotateAxis(): void;
+ approveAxis(): void;
+ setLength(e: Eighths): void;
+ setThickness(e: Eighths): void;
+ calculate(): Promise;
+ back(): void;
+ getInventoryUrl(): string | null;
+ dispose(): void;
+}
+
+// The one writer. Owns the worker port, the single debounce timer, and the draft.
+// Every transition to an approved branch persists; no explicit "save".
+export function createCaptureSession(): CaptureSession {
+ const capture = writable({ k: 'addPhoto' });
+ const busy = writable(false);
+ let state: Capture = { k: 'addPhoto' };
+ let worker: Worker | null = null;
+ let jobId = 0;
+ let timer: ReturnType | null = null;
+ let passStarted = 0;
+ let busyTimer: ReturnType | null = null;
+ let pngUrl: string | null = null;
+ let resumed = false;
+
+ const set = (next: Capture) => {
+ state = next;
+ capture.set(next);
+ };
+
+ const teardownWorker = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ if (worker) {
+ worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
+ worker.terminate();
+ worker = null;
+ }
+ if (pngUrl) {
+ URL.revokeObjectURL(pngUrl);
+ pngUrl = null;
+ }
+ };
+
+ const makeWorker = (blob: Blob) => {
+ if (worker) worker.terminate();
+ worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
+ worker.onmessage = (e: MessageEvent) => onWorkerOut(e.data);
+ worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
+ };
+
+ const onWorkerOut = (msg: WorkerOut) => {
+ if (msg.id < jobId - 1) return; // stale
+ if (msg.kind === 'decoded') {
+ if (state.k === 'mask') {
+ set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
+ }
+ schedulePass();
+ return;
+ }
+ busy.set(false);
+ if (busyTimer) {
+ clearTimeout(busyTimer);
+ busyTimer = null;
+ }
+ if (state.k !== 'mask') return;
+ if (msg.kind === 'failed') {
+ set({ ...state, failed: true });
+ return;
+ }
+ const mask: Mask = {
+ params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
+ slabPx: msg.slabPx,
+ bitset: msg.bitset,
+ bbox: msg.bbox,
+ slabBounds: msg.slabBounds,
+ };
+ set({ ...state, overlay: mask, failed: false });
+ };
+
+ const runPass = () => {
+ if (state.k !== 'mask' || !worker) return;
+ passStarted = Date.now();
+ busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
+ worker.postMessage({
+ id: ++jobId,
+ kind: 'mask',
+ sheet: state.control.sheet,
+ sensitivity: state.control.sensitivity,
+ } satisfies WorkerIn);
+ };
+
+ // The 2.0s debounce: re-armed on every control change; a sheet change
+ // recomputes immediately (spec allows), so it clears the pending pass.
+ const schedulePass = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(runPass, DEBOUNCE_MS);
+ };
+
+ const draftFromState = (): Draft | null => {
+ const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds });
+ if (state.k === 'axis') {
+ return {
+ version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params,
+ mask: strip(state.approvedMask), heading: state.heading,
+ lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
+ };
+ }
+ if (state.k === 'measure') {
+ const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
+ return {
+ version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params,
+ mask: strip(state.approvedMask), heading: state.axis.heading,
+ lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
+ results: state.results ? rest : null,
+ inventoryPng: state.results?.inventoryPng ?? null,
+ };
+ }
+ return null;
+ };
+
+ const persist = async () => {
+ const d = draftFromState();
+ if (d) await saveDraft(d as Draft);
+ };
+
+ // Re-derive the mask bitset from the stored photo + params so Measure can run.
+ const rederiveMask = (source: Source, params: MaskParams, stub: Omit): Promise =>
+ new Promise((resolve, reject) => {
+ const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
+ let id = 0;
+ const fail = (reason: string) => {
+ w.terminate();
+ reject(new Error(reason));
+ };
+ w.onmessage = (e: MessageEvent) => {
+ const msg = e.data;
+ if (msg.kind === 'decoded') {
+ w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
+ return;
+ }
+ if (msg.kind === 'failed') {
+ fail(msg.reason);
+ return;
+ }
+ if (msg.kind === 'ready') {
+ w.terminate();
+ resolve({
+ params,
+ slabPx: msg.slabPx || stub.slabPx,
+ bitset: msg.bitset,
+ bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
+ slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
+ });
+ }
+ };
+ w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
+ });
+
+ const restoreFromDraft = async (d: Draft) => {
+ const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
+ if (d.stage === 'mask') {
+ teardownWorker();
+ set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
+ makeWorker(source.blob);
+ return;
+ }
+ teardownWorker();
+ try {
+ const mask = await rederiveMask(source, d.maskParams, d.mask);
+ if (d.stage === 'axis') {
+ set({ k: 'axis', source, approvedMask: mask, heading: d.heading });
+ return;
+ }
+ if (d.inventoryPng) {
+ if (pngUrl) URL.revokeObjectURL(pngUrl);
+ pngUrl = URL.createObjectURL(d.inventoryPng);
+ }
+ set({
+ k: 'measure', source, approvedMask: mask,
+ axis: { bbox: mask.bbox, heading: d.heading },
+ lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
+ results: d.results
+ ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
+ : null,
+ });
+ } catch {
+ set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
+ makeWorker(source.blob);
+ }
+ };
+
+ return {
+ capture,
+ busy,
+
+ async resumeProbe() {
+ if (resumed) return;
+ resumed = true;
+ const d = await loadDraft();
+ if (d) set({ k: 'resume', draft: d });
+ },
+
+ continueDraft() {
+ if (state.k !== 'resume') return;
+ void restoreFromDraft(state.draft);
+ },
+
+ async startFresh() {
+ if (state.k !== 'resume') return;
+ teardownWorker();
+ await clearDraft();
+ set({ k: 'addPhoto' });
+ },
+
+ async addPhoto(file: File) {
+ teardownWorker();
+ set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false });
+ makeWorker(file);
+ },
+
+ retakes() {
+ teardownWorker();
+ set({ k: 'addPhoto' });
+ },
+
+ setSensitivity(n: number) {
+ if (state.k !== 'mask') return;
+ const s = Math.max(0, Math.min(100, Math.round(n)));
+ set({ ...state, control: { ...state.control, sensitivity: s } });
+ schedulePass();
+ },
+
+ setSheet(s: Sheet) {
+ if (state.k !== 'mask') return;
+ set({ ...state, control: { ...state.control, sheet: s } });
+ runPass();
+ },
+
+ approveMask() {
+ if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
+ teardownWorker();
+ set({ k: 'axis', source: state.source, approvedMask: state.overlay, heading: 1 });
+ void persist();
+ },
+
+ rotateAxis() {
+ if (state.k !== 'axis') return;
+ set({ ...state, heading: state.heading === 1 ? -1 : 1 });
+ void persist();
+ },
+
+ approveAxis() {
+ if (state.k !== 'axis') return;
+ set({
+ k: 'measure', source: state.source, approvedMask: state.approvedMask,
+ axis: { bbox: state.approvedMask.bbox, heading: state.heading },
+ lengthEighths: 0, thicknessEighths: 0, results: null,
+ });
+ void persist();
+ },
+
+ setLength(e: Eighths) {
+ if (state.k !== 'measure') return;
+ set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
+ void persist();
+ },
+
+ setThickness(e: Eighths) {
+ if (state.k !== 'measure') return;
+ set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
+ void persist();
+ },
+
+ async calculate() {
+ if (state.k !== 'measure') return;
+ const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
+ if (lengthEighths === 0 || thicknessEighths === 0) return;
+ const bitmap = await createImageBitmap(source.blob);
+ const base = measure(
+ approvedMask.bitset, source.widthPx, source.heightPx,
+ axis.bbox, axis.heading, lengthEighths, thicknessEighths, approvedMask.slabPx,
+ );
+ let pngBlob: Blob;
+ let belowFloor = false;
+ try {
+ const r = await encodeInventoryPng(bitmap, approvedMask.slabBounds);
+ pngBlob = r.blob;
+ belowFloor = r.sourceBelowFloor;
+ } catch {
+ pngBlob = new Blob();
+ }
+ bitmap.close();
+ if (pngUrl) URL.revokeObjectURL(pngUrl);
+ pngUrl = URL.createObjectURL(pngBlob);
+ const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
+ set({ ...state, results });
+ void persist();
+ },
+
+ back() {
+ if (state.k === 'mask') {
+ teardownWorker();
+ set({ k: 'addPhoto' });
+ } else if (state.k === 'axis') {
+ // back to mask keeps the last approved mask as the live overlay
+ const m = state.approvedMask;
+ set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
+ } else if (state.k === 'measure') {
+ set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, heading: state.axis.heading });
+ }
+ },
+
+ getInventoryUrl() {
+ return pngUrl;
+ },
+
+ dispose() {
+ teardownWorker();
+ },
+ };
+}
+
+// Display helper: format eighths as inches with fractions.
+export function fmtEighths(e: Eighths): string {
+ const whole = Math.floor(eighthsToIn(e));
+ const rem = e - whole * 8;
+ if (rem === 0) return `${whole}"`;
+ const frac: Record = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
+ const part = frac[rem] ?? `${rem}/8`;
+ return whole > 0 ? `${whole} ${part}"` : `${part}"`;
+}
diff --git a/frontend/src/draft/idb.ts b/frontend/src/draft/idb.ts
new file mode 100644
index 0000000..73f822d
--- /dev/null
+++ b/frontend/src/draft/idb.ts
@@ -0,0 +1,25 @@
+import { get, set, del } from 'idb-keyval';
+import type { Draft } from '../capture/capture.types';
+
+const KEY = 'slabuploader-draft';
+
+export async function loadDraft(): Promise {
+ try {
+ const d = await get(KEY);
+ return d && d.version === 1 ? d : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function saveDraft(d: Draft): Promise {
+ await set(KEY, d);
+}
+
+export async function clearDraft(): Promise {
+ try {
+ await del(KEY);
+ } catch {
+ // idb unavailable: draft persistence is best-effort, app state is not
+ }
+}
diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts
new file mode 100644
index 0000000..50e4b20
--- /dev/null
+++ b/frontend/src/geometry/rect.ts
@@ -0,0 +1,94 @@
+import type { ABox, BBox } from '../capture/capture.types';
+
+export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
+ let minX = w, minY = h, maxX = -1, maxY = -1;
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; x++) {
+ if (bitset[row + x]) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ }
+ }
+ if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
+ return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
+}
+
+// Convex hull (Andrew's monotone chain). Points are [x, y].
+export function convexHull(pts: [number, number][]): [number, number][] {
+ const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+ if (p.length < 3) return p;
+ const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
+ (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+ const lower: [number, number][] = [];
+ for (const pt of p) {
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
+ lower.push(pt);
+ }
+ const upper: [number, number][] = [];
+ for (let i = p.length - 1; i >= 0; i--) {
+ const pt = p[i];
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
+ upper.push(pt);
+ }
+ lower.pop();
+ upper.pop();
+ return lower.concat(upper);
+}
+
+// Rotating calipers: true minimum-area bounding rectangle of the mask.
+export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
+ const pts: [number, number][] = [];
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
+ }
+ if (pts.length < 2) return null;
+ const hull = convexHull(pts);
+
+ let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
+ const n = hull.length;
+ for (let i = 0; i < n; i++) {
+ const j = (i + 1) % n;
+ const ex = hull[j][0] - hull[i][0];
+ const ey = hull[j][1] - hull[i][1];
+ const len = Math.hypot(ex, ey);
+ if (len === 0) continue;
+ const ux = ex / len, uy = ey / len;
+ const vx = -uy, vy = ux;
+ let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
+ for (let k = 0; k < n; k++) {
+ const dx = hull[k][0] - hull[i][0];
+ const dy = hull[k][1] - hull[i][1];
+ const u = dx * ux + dy * uy;
+ const v = dx * vx + dy * vy;
+ if (u < minU) minU = u;
+ if (u > maxU) maxU = u;
+ if (v < minV) minV = v;
+ if (v > maxV) maxV = v;
+ }
+ const rw = maxU - minU;
+ const rh = maxV - minV;
+ const area = rw * rh;
+ if (area < best.area) {
+ best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
+ }
+ }
+
+ // long-side direction: the edge with the larger extent
+ const lu = best.hw >= best.hh ? best.ux : -best.uy;
+ const lv = best.hw >= best.hh ? best.uy : best.ux;
+ const su = best.hw >= best.hh ? -best.uy : best.ux;
+ const sv = best.hw >= best.hh ? -best.ux : -best.uy;
+ const halfL = best.hw >= best.hh ? best.hw : best.hh;
+ const halfS = best.hw >= best.hh ? best.hh : best.hw;
+ const corners: [number, number][] = [];
+ for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
+ corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
+ }
+ const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
+ return { corners, longSidePx: halfL * 2, angleDeg };
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..7996954
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,4 @@
+import App from './App.svelte';
+
+const root = document.querySelector('#app');
+if (root) new App({ target: root });
diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts
new file mode 100644
index 0000000..df3269d
--- /dev/null
+++ b/frontend/src/measure/measure.ts
@@ -0,0 +1,75 @@
+import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
+
+// Long-side start point per heading: heading 1 starts at corners[1] so that
+// the axis reads corners[1] -> corners[0] (see rect.ts corner order).
+export function axisEndpoints(bbox: BBox, heading: 1 | -1): { start: [number, number]; end: [number, number] } {
+ return heading === 1 ? { start: bbox.corners[1], end: bbox.corners[0] } : { start: bbox.corners[0], end: bbox.corners[1] };
+}
+
+// Sampled every 6 in along the axis; each sample is a line probe perpendicular
+// to the axis, width = max run of slab px across the probe.
+function sampleWidths(
+ bitset: Uint8Array, w: number, h: number,
+ start: [number, number], end: [number, number],
+ scale: number, lengthIn: number,
+): Eighths[] {
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ const len = Math.hypot(dx, dy) || 1;
+ const ax = dx / len, ay = dy / len;
+ const px = -ay, py = ax; // perpendicular
+ const stepPx = 6 * scale;
+ const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
+ const out: Eighths[] = [];
+ const half = Math.max(2, Math.round((w + h) / 4));
+ for (let i = 0; i < count; i++) {
+ const t = (i / (count - 1 || 1)) * len;
+ const ox = start[0] + ax * t;
+ const oy = start[1] + ay * t;
+ let minP = Infinity, maxP = -Infinity;
+ for (let p = -half; p <= half; p++) {
+ const x = Math.round(ox + px * p);
+ const y = Math.round(oy + py * p);
+ if (x < 0 || y < 0 || x >= w || y >= h) continue;
+ if (bitset[y * w + x]) {
+ if (p < minP) minP = p;
+ if (p > maxP) maxP = p;
+ }
+ }
+ if (maxP < minP) continue; // no slab px on this probe
+ out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
+ }
+ return out;
+}
+
+export function measure(
+ bitset: Uint8Array, w: number, h: number,
+ bbox: BBox, heading: 1 | -1,
+ lengthEighths: Eighths, thicknessEighths: Eighths,
+ slabPx: number,
+): Omit & { sourceBelowFloor: false } {
+ const lengthIn = lengthEighths / 8;
+ const scale = bbox.longSidePx / lengthIn;
+ const { start, end } = axisEndpoints(bbox, heading);
+ const every6In = sampleWidths(bitset, w, h, start, end, scale, lengthIn);
+ let widths: Widths;
+ if (every6In.length > 0) {
+ const min = Math.min(...every6In);
+ const max = Math.max(...every6In);
+ const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
+ widths = { min, max, avg, every6In };
+ } else {
+ widths = { min: 0, max: 0, avg: 0, every6In: [] };
+ }
+ const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
+ const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
+ return {
+ lengthEighths,
+ thicknessEighths,
+ scale,
+ widths,
+ sqft,
+ bdft,
+ sourceBelowFloor: false,
+ };
+}
diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts
new file mode 100644
index 0000000..acc69cc
--- /dev/null
+++ b/frontend/src/png/inventory.ts
@@ -0,0 +1,38 @@
+import type { ABox } from '../capture/capture.types';
+
+const FLOOR = 1600; // shorter side, px
+const FILL = 0.8; // 80% fill at widest and tallest points
+const MARGIN = 0.08; // crop padding around slab bounds
+const ASPECT_W = 3;
+const ASPECT_H = 4;
+
+// 3:4 portrait RGBA PNG: slab centered at 80% fill (widest and tallest points),
+// shorter side >= 1600 when the source can supply it. No upscale: an undersized
+// source yields sourceBelowFloor = true (warn + require a new photo).
+export async function encodeInventoryPng(
+ bitmap: ImageBitmap,
+ slab: ABox,
+): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
+ const cw = Math.round(slab.w * (1 + 2 * MARGIN));
+ const ch = Math.round(slab.h * (1 + 2 * MARGIN));
+ const sx = Math.max(0, Math.round(slab.x - slab.w * MARGIN));
+ const sy = Math.max(0, Math.round(slab.y - slab.h * MARGIN));
+ const srcW = Math.min(bitmap.width - sx, Math.max(1, cw));
+ const srcH = Math.min(bitmap.height - sy, Math.max(1, ch));
+
+ const belowFloor = Math.min(srcW, srcH) < FLOOR;
+ // Portrait 3:4: width is the shorter side. Meet the floor when source allows.
+ const outW = belowFloor ? Math.max(1, Math.min(srcW, FLOOR)) : FLOOR;
+ const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
+ const canvas = new OffscreenCanvas(outW, outH);
+ const ctx = canvas.getContext('2d')!;
+ ctx.clearRect(0, 0, outW, outH);
+ const maxW = outW * FILL;
+ const maxH = outH * FILL;
+ const scale = Math.min(maxW / srcW, maxH / srcH);
+ const dw = srcW * scale;
+ const dh = srcH * scale;
+ ctx.drawImage(bitmap, sx, sy, srcW, srcH, (outW - dw) / 2, (outH - dh) / 2, dw, dh);
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
+ return { blob, sourceBelowFloor: belowFloor };
+}
diff --git a/frontend/src/worker/mask.worker.ts b/frontend/src/worker/mask.worker.ts
new file mode 100644
index 0000000..d7caa6c
--- /dev/null
+++ b/frontend/src/worker/mask.worker.ts
@@ -0,0 +1,107 @@
+import { minAreaRect, slabBounds } from '../geometry/rect';
+import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
+
+let bitmap: ImageBitmap | null = null;
+let data: Uint8ClampedArray | null = null;
+let w = 0, h = 0;
+let current = 0; // highest job id issued by main; older replies are dropped
+
+// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
+const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
+ const t = s / 100;
+ const isSheet = g - Math.max(r, b) > 10 + 35 * t;
+ return !isSheet;
+};
+const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ const isSheet = lum < 40 + 140 * (s / 100);
+ return !isSheet;
+};
+const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
+ let gr = 0, gl = 0;
+ for (const p of samples) {
+ if (p.g - Math.max(p.r, p.b) > 20) gr++;
+ if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
+ }
+ return gl >= gr ? 'black' : 'green';
+};
+
+function runMask(params: MaskParams, id: number): WorkerOut {
+ if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
+ let sheet = params.sheet;
+ if (sheet === 'auto') {
+ const samples: { r: number; g: number; b: number }[] = [];
+ for (let y = 0; y < h; y += 16)
+ for (let x = 0; x < w; x += 16) {
+ const i = (y * w + x) * 4;
+ samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
+ }
+ sheet = autoPick(samples);
+ }
+ const keep = sheet === 'green' ? greenKeep : blackKeep;
+ const s = Math.max(0, Math.min(100, params.sensitivity));
+ const bitset = new Uint8Array(w * h);
+ let slabPx = 0;
+ for (let i = 0, p = 0; i < data.length; i += 4, p++) {
+ if (keep(data[i], data[i + 1], data[i + 2], s)) {
+ bitset[p] = 1;
+ slabPx++;
+ }
+ }
+ if (slabPx === 0) {
+ return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
+ }
+ const bbox = minAreaRect(bitset, w, h);
+ if (!bbox) {
+ return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
+ }
+ return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) };
+}
+
+self.onmessage = (e: MessageEvent) => {
+ const msg = e.data;
+ if (msg.id < current) return; // superseded by a newer job
+ current = msg.id;
+ if (msg.kind === 'init') {
+ if (bitmap) {
+ bitmap.close();
+ bitmap = null;
+ }
+ data = null;
+ w = h = 0;
+ createImageBitmap(msg.blob)
+ .then((bmp) => {
+ w = bmp.width;
+ h = bmp.height;
+ const canvas = new OffscreenCanvas(w, h);
+ const ctx = canvas.getContext('2d')!;
+ ctx.drawImage(bmp, 0, 0);
+ data = ctx.getImageData(0, 0, w, h).data;
+ bitmap = bmp;
+ self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut);
+ })
+ .catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut));
+ return;
+ }
+ if (msg.kind === 'invalidate') {
+ if (bitmap) {
+ bitmap.close();
+ bitmap = null;
+ }
+ data = null;
+ w = h = 0;
+ return;
+ }
+ try {
+ const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id);
+ if (out.kind === 'ready') {
+ self.postMessage(out, [out.bitset.buffer]);
+ } else {
+ self.postMessage(out);
+ }
+ } catch (err) {
+ self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut);
+ }
+};
+
+export {};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..6eaf9f3
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": false,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..d6d3d75
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+import sveltePreprocess from 'svelte-preprocess';
+
+export default defineConfig({
+ plugins: [svelte({ preprocess: sveltePreprocess() })],
+ worker: { format: 'es' },
+});