diff --git a/Dockerfile b/Dockerfile
index 845803b..e8b24eb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,21 @@
# syntax=docker/dockerfile:1
-# ── Build ──────────────────────────────────────────────────────────────
-# Runs on the builder's native platform; cross-compiles to the target arch.
+# ── Frontend ───────────────────────────────────────────────────────────
+# Built first so the Go stage can embed the result. Runs on the builder's
+# native platform; the output is architecture-independent.
+FROM --platform=$BUILDPLATFORM node:22-alpine AS web
+
+WORKDIR /web
+RUN corepack enable
+
+# Dependency layer, cached independently of source changes.
+COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY web/ ./
+RUN pnpm build
+
+# ── Go build ───────────────────────────────────────────────────────────
# CGO_ENABLED=0 works because the SQLite driver (modernc.org/sqlite) is pure
# Go, so there is no cgo and no libc to match against.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
@@ -12,11 +26,12 @@ ARG VERSION=dev
WORKDIR /src
-# Cache the module layer separately from the source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
+# Overlay the freshly built UI over the committed placeholder.
+COPY --from=web /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
diff --git a/Makefile b/Makefile
index 91d04a4..a7790d2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,14 +1,23 @@
# Vellum — developer commands.
#
-# Requires Go >= 1.26. On Fedora: sudo dnf install golang
+# Requires Go >= 1.26 and pnpm. On Fedora: sudo dnf install golang
+# Frontend: cd web && pnpm install
BINARY := vellum
-VERSION ?= 0.1.0-dev
+VERSION ?= 0.4.0-dev
LDFLAGS := -s -w -X main.version=$(VERSION)
-.PHONY: build run test fmt vet tidy check docker clean
+.PHONY: build web run test fmt vet tidy check docker clean
-build:
+# Build the frontend first: the Go build embeds web/dist.
+build: web
+ CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
+
+web:
+ cd web && pnpm install --silent && pnpm build
+
+# Go-only build, for when the frontend is unchanged.
+build-go:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
run:
@@ -34,4 +43,4 @@ docker:
clean:
rm -f $(BINARY)
- rm -rf data config
+ rm -rf data config web/dist/*.js web/dist/*.css web/dist/assets web/dist/index.html
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index a9dff54..1e84627 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -38,6 +38,7 @@ func New(db *sql.DB, log *slog.Logger, version string) *Server {
mux: http.NewServeMux(),
}
s.routes()
+ s.mountWeb()
return s
}
@@ -56,7 +57,6 @@ func (s *Server) Handle(pattern string, h http.Handler) {
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
- s.mux.HandleFunc("/", s.handleNotFound)
}
// Handler returns the fully wrapped root handler.
diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go
index 993c566..b45b093 100644
--- a/internal/httpapi/server_test.go
+++ b/internal/httpapi/server_test.go
@@ -54,8 +54,9 @@ func TestHealth(t *testing.T) {
func TestNotFoundIsJSON(t *testing.T) {
srv := newTestServer(t)
+ // API paths must never fall back to the SPA shell.
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/nope", nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
diff --git a/internal/httpapi/static.go b/internal/httpapi/static.go
new file mode 100644
index 0000000..51b5e69
--- /dev/null
+++ b/internal/httpapi/static.go
@@ -0,0 +1,61 @@
+package httpapi
+
+import (
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/mithral/vellum/web"
+)
+
+// mountWeb serves the embedded single-page app at the root path.
+//
+// Unknown non-API paths fall back to index.html so client-side routes work on
+// reload. API paths never fall back: a typo in an endpoint returns JSON, not
+// an HTML page.
+func (s *Server) mountWeb() {
+ sub, err := fs.Sub(web.Dist, "dist")
+ if err != nil {
+ s.mux.HandleFunc("/", s.handleNotFound)
+ return
+ }
+ index, err := fs.ReadFile(sub, "index.html")
+ if err != nil {
+ // Frontend not built into this binary. Keep JSON 404s rather than
+ // pretending the app exists.
+ s.log.Warn("web UI not embedded; run the frontend build to include it")
+ s.mux.HandleFunc("/", s.handleNotFound)
+ return
+ }
+
+ files := http.FileServer(http.FS(sub))
+ s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if isAPIPath(r.URL.Path) {
+ s.handleNotFound(w, r)
+ return
+ }
+ if r.URL.Path == "/" || r.URL.Path == "" {
+ serveIndex(w, index)
+ return
+ }
+ name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
+ if f, err := sub.Open(name); err == nil {
+ _ = f.Close()
+ files.ServeHTTP(w, r)
+ return
+ }
+ // SPA route: hand back the shell and let the client router decide.
+ serveIndex(w, index)
+ })
+}
+
+func serveIndex(w http.ResponseWriter, index []byte) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(index)
+}
+
+func isAPIPath(p string) bool {
+ return p == "/healthz" || strings.HasPrefix(p, "/api/")
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..a3780fb
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/*
+!dist/.gitkeep
+.vite/
+*.local
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..9142f28
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,42 @@
+# Vellum web
+
+Svelte 5 + Vite + TypeScript single-page app. It is built to a static bundle in
+`dist/` and embedded into the Go binary, so all asset paths are relative
+(`base: './'` in `vite.config.ts`).
+
+## Commands
+
+```bash
+pnpm install
+pnpm dev # dev server on :5173, proxying /api to :7788
+pnpm build # writes dist/
+pnpm check # svelte-check
+```
+
+Run the Go server on `:7788` (`make run` from the repo root) and `pnpm dev`
+proxies API and health requests to it, so cookies and sessions behave exactly
+as they will in production.
+
+## Layout
+
+```
+src/
+ main.ts entry point
+ App.svelte login gate -> shell
+ app.css design tokens and base styles
+ lib/
+ api.ts typed client; mirrors the Go handlers
+ session.ts auth + library stores
+ nav.ts view state
+ components/
+ Login.svelte
+ Shell.svelte nav + view switch
+ Libraries.svelte library list, create, scan, delete
+ LibraryView.svelte item grid with covers
+ Reader.svelte epub.js reader with progress sync
+ Users.svelte admin user management
+```
+
+The reader resumes from a spine index, which is why progress positions are
+integers: it round-trips through the polymorphic `progress` table without
+needing a text column.
diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep
new file mode 100644
index 0000000..674ae75
--- /dev/null
+++ b/web/dist/.gitkeep
@@ -0,0 +1,3 @@
+The Vite build output goes here. This file keeps the directory present so the
+Go embed directive in ../embed.go is always valid, even before the frontend has
+been built. Run `pnpm build` (or `make build` from the repo root) to populate it.
diff --git a/web/embed.go b/web/embed.go
new file mode 100644
index 0000000..ff17c53
--- /dev/null
+++ b/web/embed.go
@@ -0,0 +1,13 @@
+// Package web embeds the built single-page app.
+//
+// The directory is embedded as-is; `make build` runs the frontend build first.
+// A committed .gitkeep keeps the embed directive valid even when the frontend
+// has not been built, so `go build ./...` never fails for a missing directory.
+package web
+
+import "embed"
+
+// Dist holds the Vite build output (index.html plus hashed assets).
+//
+//go:embed all:dist
+var Dist embed.FS
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..577880e
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vellum
+
+
+
+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..e037e7c
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "vellum-web",
+ "private": true,
+ "version": "0.4.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "dependencies": {
+ "epubjs": "^0.3.93"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
+ "svelte": "^5.19.0",
+ "svelte-check": "^4.1.4",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.7"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..fa881d1
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,1181 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ epubjs:
+ specifier: ^0.3.93
+ version: 0.3.93
+ devDependencies:
+ '@sveltejs/vite-plugin-svelte':
+ specifier: ^5.0.3
+ version: 5.1.1(svelte@5.57.2)(vite@6.4.4)
+ svelte:
+ specifier: ^5.19.0
+ version: 5.57.2
+ svelte-check:
+ specifier: ^4.1.4
+ version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@5.9.3)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.7
+ version: 6.4.4
+
+packages:
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-android-arm-eabi@4.64.3':
+ resolution: {integrity: sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.64.3':
+ resolution: {integrity: sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.64.3':
+ resolution: {integrity: sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.64.3':
+ resolution: {integrity: sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.64.3':
+ resolution: {integrity: sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.64.3':
+ resolution: {integrity: sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.3':
+ resolution: {integrity: sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.3':
+ resolution: {integrity: sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.3':
+ resolution: {integrity: sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.64.3':
+ resolution: {integrity: sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.3':
+ resolution: {integrity: sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.64.3':
+ resolution: {integrity: sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.3':
+ resolution: {integrity: sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.3':
+ resolution: {integrity: sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.3':
+ resolution: {integrity: sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.3':
+ resolution: {integrity: sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.3':
+ resolution: {integrity: sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.64.3':
+ resolution: {integrity: sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.64.3':
+ resolution: {integrity: sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.64.3':
+ resolution: {integrity: sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.64.3':
+ resolution: {integrity: sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.3':
+ resolution: {integrity: sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.3':
+ resolution: {integrity: sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.64.3':
+ resolution: {integrity: sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.64.3':
+ resolution: {integrity: sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@sveltejs/acorn-typescript@1.0.13':
+ resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==}
+ peerDependencies:
+ acorn: ^8.9.0
+
+ '@sveltejs/load-config@0.2.3':
+ resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==}
+ engines: {node: '>= 18.0.0'}
+
+ '@sveltejs/vite-plugin-svelte-inspector@4.0.1':
+ resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22}
+ peerDependencies:
+ '@sveltejs/vite-plugin-svelte': ^5.0.0
+ svelte: ^5.0.0
+ vite: ^6.0.0
+
+ '@sveltejs/vite-plugin-svelte@5.1.1':
+ resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22}
+ peerDependencies:
+ svelte: ^5.0.0
+ vite: ^6.0.0
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/localforage@0.0.34':
+ resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==}
+ deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!
+
+ '@xmldom/xmldom@0.7.13':
+ resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
+ engines: {node: '>=10.0.0'}
+ deprecated: this version has critical issues, please update to the latest version
+
+ acorn@8.19.0:
+ resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ aria-query@5.3.1:
+ resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
+ engines: {node: '>= 0.4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ core-js@3.50.0:
+ resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==}
+
+ core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
+ d@1.0.2:
+ resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
+ engines: {node: '>=0.12'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
+ devalue@5.9.4:
+ resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}
+
+ epubjs@0.3.93:
+ resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==}
+
+ es5-ext@0.10.64:
+ resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
+ engines: {node: '>=0.10'}
+
+ es6-iterator@2.0.3:
+ resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
+
+ es6-symbol@3.1.4:
+ resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ esm-env@1.2.2:
+ resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
+
+ esniff@2.0.1:
+ resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
+ engines: {node: '>=0.10'}
+
+ esrap@2.4.0:
+ resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==}
+ peerDependencies:
+ '@typescript-eslint/types': ^8.2.0
+ peerDependenciesMeta:
+ '@typescript-eslint/types':
+ optional: true
+
+ event-emitter@0.3.5:
+ resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
+
+ ext@1.7.0:
+ resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ is-reference@3.0.3:
+ resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
+
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
+ jszip@3.10.2:
+ resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
+
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
+ lie@3.1.1:
+ resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
+
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
+ localforage@1.10.0:
+ resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
+
+ locate-character@3.0.0:
+ resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
+
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ marks-pane@1.0.9:
+ resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==}
+
+ mri@1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
+ engines: {node: '>=4'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ next-tick@1.1.0:
+ resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
+
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
+ path-webpack@0.0.3:
+ resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.29:
+ resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ rollup@4.64.3:
+ resolution: {integrity: sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ sade@1.8.1:
+ resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
+ engines: {node: '>=6'}
+
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
+ svelte-check@4.7.6:
+ resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==}
+ engines: {node: '>= 18.0.0'}
+ hasBin: true
+ peerDependencies:
+ svelte: ^4.0.0 || ^5.0.0-next.0
+ typescript: ^5.0.0 || ^6.0.0
+
+ svelte@5.57.2:
+ resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ type@2.7.3:
+ resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@6.4.4:
+ resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitefu@1.1.3:
+ resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
+ zimmerframe@1.1.5:
+ resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
+
+snapshots:
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.64.3':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.64.3':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.64.3':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.3':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.64.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.64.3':
+ optional: true
+
+ '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)':
+ dependencies:
+ acorn: 8.19.0
+
+ '@sveltejs/load-config@0.2.3': {}
+
+ '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4))(svelte@5.57.2)(vite@6.4.4)':
+ dependencies:
+ '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.57.2)(vite@6.4.4)
+ debug: 4.4.3
+ svelte: 5.57.2
+ vite: 6.4.4
+ transitivePeerDependencies:
+ - supports-color
+
+ '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4)':
+ dependencies:
+ '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4))(svelte@5.57.2)(vite@6.4.4)
+ debug: 4.4.3
+ deepmerge: 4.3.1
+ kleur: 4.1.5
+ magic-string: 0.30.21
+ svelte: 5.57.2
+ vite: 6.4.4
+ vitefu: 1.1.3(vite@6.4.4)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@types/estree@1.0.9': {}
+
+ '@types/localforage@0.0.34':
+ dependencies:
+ localforage: 1.10.0
+
+ '@xmldom/xmldom@0.7.13': {}
+
+ acorn@8.19.0: {}
+
+ aria-query@5.3.1: {}
+
+ axobject-query@4.1.0: {}
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ clsx@2.1.1: {}
+
+ core-js@3.50.0: {}
+
+ core-util-is@1.0.3: {}
+
+ d@1.0.2:
+ dependencies:
+ es5-ext: 0.10.64
+ type: 2.7.3
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deepmerge@4.3.1: {}
+
+ devalue@5.9.4: {}
+
+ epubjs@0.3.93:
+ dependencies:
+ '@types/localforage': 0.0.34
+ '@xmldom/xmldom': 0.7.13
+ core-js: 3.50.0
+ event-emitter: 0.3.5
+ jszip: 3.10.2
+ localforage: 1.10.0
+ lodash: 4.18.1
+ marks-pane: 1.0.9
+ path-webpack: 0.0.3
+
+ es5-ext@0.10.64:
+ dependencies:
+ es6-iterator: 2.0.3
+ es6-symbol: 3.1.4
+ esniff: 2.0.1
+ next-tick: 1.1.0
+
+ es6-iterator@2.0.3:
+ dependencies:
+ d: 1.0.2
+ es5-ext: 0.10.64
+ es6-symbol: 3.1.4
+
+ es6-symbol@3.1.4:
+ dependencies:
+ d: 1.0.2
+ ext: 1.7.0
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ esm-env@1.2.2: {}
+
+ esniff@2.0.1:
+ dependencies:
+ d: 1.0.2
+ es5-ext: 0.10.64
+ event-emitter: 0.3.5
+ type: 2.7.3
+
+ esrap@2.4.0:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ event-emitter@0.3.5:
+ dependencies:
+ d: 1.0.2
+ es5-ext: 0.10.64
+
+ ext@1.7.0:
+ dependencies:
+ type: 2.7.3
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ immediate@3.0.6: {}
+
+ inherits@2.0.4: {}
+
+ is-reference@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ isarray@1.0.0: {}
+
+ jszip@3.10.2:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
+ kleur@4.1.5: {}
+
+ lie@3.1.1:
+ dependencies:
+ immediate: 3.0.6
+
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
+ localforage@1.10.0:
+ dependencies:
+ lie: 3.1.1
+
+ locate-character@3.0.0: {}
+
+ lodash@4.18.1: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ marks-pane@1.0.9: {}
+
+ mri@1.2.0: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.20: {}
+
+ next-tick@1.1.0: {}
+
+ pako@1.0.11: {}
+
+ path-webpack@0.0.3: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ postcss@8.5.29:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ process-nextick-args@2.0.1: {}
+
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
+ readdirp@4.1.2: {}
+
+ rollup@4.64.3:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.64.3
+ '@rollup/rollup-android-arm64': 4.64.3
+ '@rollup/rollup-darwin-arm64': 4.64.3
+ '@rollup/rollup-darwin-x64': 4.64.3
+ '@rollup/rollup-freebsd-arm64': 4.64.3
+ '@rollup/rollup-freebsd-x64': 4.64.3
+ '@rollup/rollup-linux-arm-gnueabihf': 4.64.3
+ '@rollup/rollup-linux-arm-musleabihf': 4.64.3
+ '@rollup/rollup-linux-arm64-gnu': 4.64.3
+ '@rollup/rollup-linux-arm64-musl': 4.64.3
+ '@rollup/rollup-linux-loong64-gnu': 4.64.3
+ '@rollup/rollup-linux-loong64-musl': 4.64.3
+ '@rollup/rollup-linux-ppc64-gnu': 4.64.3
+ '@rollup/rollup-linux-ppc64-musl': 4.64.3
+ '@rollup/rollup-linux-riscv64-gnu': 4.64.3
+ '@rollup/rollup-linux-riscv64-musl': 4.64.3
+ '@rollup/rollup-linux-s390x-gnu': 4.64.3
+ '@rollup/rollup-linux-x64-gnu': 4.64.3
+ '@rollup/rollup-linux-x64-musl': 4.64.3
+ '@rollup/rollup-openbsd-x64': 4.64.3
+ '@rollup/rollup-openharmony-arm64': 4.64.3
+ '@rollup/rollup-win32-arm64-msvc': 4.64.3
+ '@rollup/rollup-win32-ia32-msvc': 4.64.3
+ '@rollup/rollup-win32-x64-gnu': 4.64.3
+ '@rollup/rollup-win32-x64-msvc': 4.64.3
+ fsevents: 2.3.3
+
+ sade@1.8.1:
+ dependencies:
+ mri: 1.2.0
+
+ safe-buffer@5.1.2: {}
+
+ setimmediate@1.0.5: {}
+
+ source-map-js@1.2.2: {}
+
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
+ svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@5.9.3):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ '@sveltejs/load-config': 0.2.3
+ chokidar: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.7)
+ picocolors: 1.1.1
+ sade: 1.8.1
+ svelte: 5.57.2
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - picomatch
+
+ svelte@5.57.2:
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@sveltejs/acorn-typescript': 1.0.13(acorn@8.19.0)
+ '@types/estree': 1.0.9
+ acorn: 8.19.0
+ aria-query: 5.3.1
+ axobject-query: 4.1.0
+ clsx: 2.1.1
+ devalue: 5.9.4
+ esm-env: 1.2.2
+ esrap: 2.4.0
+ is-reference: 3.0.3
+ locate-character: 3.0.0
+ magic-string: 0.30.21
+ zimmerframe: 1.1.5
+ transitivePeerDependencies:
+ - '@typescript-eslint/types'
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ type@2.7.3: {}
+
+ typescript@5.9.3: {}
+
+ util-deprecate@1.0.2: {}
+
+ vite@6.4.4:
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.29
+ rollup: 4.64.3
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ vitefu@1.1.3(vite@6.4.4):
+ optionalDependencies:
+ vite: 6.4.4
+
+ zimmerframe@1.1.5: {}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 0000000..635ea73
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,10 @@
+# pnpm settings live here (not in package.json) as of pnpm 10+.
+#
+# esbuild ships its platform binary via a postinstall script; Vite needs it.
+# core-js and es5-ext are transitive and their scripts are not required, so they
+# are explicitly denied rather than left undecided — pnpm fails an install when
+# any build script is neither approved nor denied.
+allowBuilds:
+ esbuild: true
+ core-js: false
+ es5-ext: false
diff --git a/web/src/App.svelte b/web/src/App.svelte
new file mode 100644
index 0000000..6fea5aa
--- /dev/null
+++ b/web/src/App.svelte
@@ -0,0 +1,24 @@
+
+
+{#if !$ready}
+
+{:else if !$user}
+
+{:else}
+
+{/if}
+
+
diff --git a/web/src/app.css b/web/src/app.css
new file mode 100644
index 0000000..f1148b9
--- /dev/null
+++ b/web/src/app.css
@@ -0,0 +1,243 @@
+/* Vellum design tokens.
+ Restrained by intent: one accent, a tight spacing scale, hairline borders,
+ and no decorative gradient or shadow noise. Dark is the default surface. */
+
+:root {
+ --bg: #0b0d10;
+ --bg-raised: #131619;
+ --bg-hover: #191d21;
+ --bg-active: #1f2429;
+ --border: #23282e;
+ --border-strong: #333a42;
+
+ --text: #e8eaed;
+ --text-muted: #9aa3ad;
+ --text-faint: #6b747e;
+
+ --accent: #6d7cff;
+ --accent-hover: #8290ff;
+ --accent-text: #ffffff;
+ --danger: #ff6b6b;
+ --success: #4ade80;
+
+ --radius: 6px;
+ --radius-lg: 10px;
+
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
+ 'Noto Sans', sans-serif;
+ --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
+
+ --nav-width: 220px;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #ffffff;
+ --bg-raised: #fafafa;
+ --bg-hover: #f2f3f5;
+ --bg-active: #e9ebee;
+ --border: #e4e6ea;
+ --border-strong: #cfd3d9;
+ --text: #14171a;
+ --text-muted: #5c6672;
+ --text-faint: #8b949e;
+ --accent: #4f5bd5;
+ --accent-hover: #3f4bc4;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+
+h1,
+h2,
+h3 {
+ margin: 0;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+h1 {
+ font-size: 20px;
+}
+h2 {
+ font-size: 16px;
+}
+h3 {
+ font-size: 14px;
+}
+
+button,
+input,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+/* ── Controls ───────────────────────────────────────────────────────── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 7px 12px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ color: var(--text);
+ cursor: pointer;
+ transition: background 0.12s ease, border-color 0.12s ease;
+ white-space: nowrap;
+}
+.btn:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.btn-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-text);
+}
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+.btn-ghost {
+ border-color: transparent;
+ background: transparent;
+}
+.btn-ghost:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+
+.input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
+}
+.input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.field label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+/* ── Surfaces ───────────────────────────────────────────────────────── */
+
+.card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--bg-raised);
+}
+
+.badge {
+ display: inline-block;
+ padding: 1px 7px;
+ border-radius: 999px;
+ border: 1px solid var(--border-strong);
+ font-size: 11px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.alert {
+ padding: 10px 12px;
+ border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--danger) 10%, transparent);
+ color: var(--text);
+ font-size: 13px;
+}
+
+.muted {
+ color: var(--text-muted);
+}
+.faint {
+ color: var(--text-faint);
+}
+.mono {
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+/* ── Empty state ────────────────────────────────────────────────────── */
+
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ padding: var(--space-6) var(--space-4);
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-lg);
+ text-align: center;
+ color: var(--text-muted);
+}
+.empty h3 {
+ color: var(--text);
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--border-strong);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/web/src/epubjs.d.ts b/web/src/epubjs.d.ts
new file mode 100644
index 0000000..52e0b03
--- /dev/null
+++ b/web/src/epubjs.d.ts
@@ -0,0 +1,7 @@
+declare module 'epubjs' {
+ // epub.js ships no usable types. We only touch a small, stable surface, so a
+ // permissive declaration is better than an inaccurate hand-written one.
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const ePub: any
+ export default ePub
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..55587e8
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,160 @@
+// Typed client for the Vellum HTTP API. Every response shape here mirrors a
+// Go handler; keep them in sync when endpoints change.
+
+export interface User {
+ id: number
+ username: string
+ display_name: string
+ is_admin: boolean
+ created_at: string
+}
+
+export interface Library {
+ id: number
+ name: string
+ kind: string
+ root_path: string
+ created_at: string
+}
+
+export interface MediaType {
+ Kind: string
+ Name: string
+ Extensions: string[]
+}
+
+export interface Item {
+ id: number
+ kind: string
+ path: string
+ title: string
+ year: number
+ added_at: string
+ missing?: boolean
+}
+
+export interface Progress {
+ item_id: number
+ file_id: number
+ kind: string
+ position: number
+ duration: number
+ percent: number
+ finished: boolean
+ updated_at: string
+}
+
+export interface EbookMetadata {
+ title: string
+ authors: string[] | null
+ language: string
+ identifier: string
+ publisher: string
+ date: string
+ description: string
+ spine_length: number
+}
+
+export class ApiError extends Error {
+ status: number
+ constructor(status: number, message: string) {
+ super(message)
+ this.status = status
+ this.name = 'ApiError'
+ }
+}
+
+async function request(method: string, path: string, body?: unknown): Promise {
+ const res = await fetch(path, {
+ method,
+ credentials: 'same-origin',
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+
+ if (res.status === 204) return undefined as T
+
+ const text = await res.text()
+ let parsed: unknown = undefined
+ if (text) {
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ // Fall through to the status-based error below.
+ }
+ }
+
+ if (!res.ok) {
+ const message =
+ parsed && typeof parsed === 'object' && 'error' in parsed
+ ? String((parsed as { error: unknown }).error)
+ : `request failed (${res.status})`
+ throw new ApiError(res.status, message)
+ }
+ return parsed as T
+}
+
+export const api = {
+ login: (username: string, password: string) =>
+ request<{ user: User; expires_in: number }>('POST', '/api/v1/auth/login', { username, password }),
+
+ logout: () => request<{ status: string }>('POST', '/api/v1/auth/logout'),
+
+ me: () => request<{ user: User; visible_libraries: number[] }>('GET', '/api/v1/auth/me'),
+
+ changePassword: (current_password: string, new_password: string) =>
+ request<{ status: string }>('POST', '/api/v1/auth/password', { current_password, new_password }),
+
+ mediaTypes: () => request<{ media_types: MediaType[] }>('GET', '/api/v1/media-types'),
+
+ libraries: () => request<{ libraries: Library[] }>('GET', '/api/v1/libraries'),
+
+ library: (id: number) =>
+ request<{ library: Library; item_count: number; reachable: boolean }>('GET', `/api/v1/libraries/${id}`),
+
+ createLibrary: (name: string, kind: string, root_path: string) =>
+ request<{ library: Library }>('POST', '/api/v1/libraries', { name, kind, root_path }),
+
+ deleteLibrary: (id: number) => request('DELETE', `/api/v1/libraries/${id}`),
+
+ scanLibrary: (id: number) => request('POST', `/api/v1/libraries/${id}/scan`),
+
+ items: (libraryId: number, limit = 200) =>
+ request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`),
+
+ item: (id: number) =>
+ request<{ item: Item; files: { id: number; path: string; size: number; container: string }[]; has_cover: boolean; progress?: Progress }>(
+ 'GET',
+ `/api/v1/items/${id}`,
+ ),
+
+ ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
+
+ coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
+
+ fileUrl: (id: number) => `/api/v1/items/${id}/file`,
+
+ progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
+
+ putProgress: (id: number, p: { position?: number; duration?: number; percent?: number; finished?: boolean }) =>
+ request<{ progress: Progress }>('PUT', `/api/v1/items/${id}/progress`, p),
+
+ recent: () => request<{ progress: Progress[] }>('GET', '/api/v1/progress/recent'),
+
+ users: () => request<{ users: User[] }>('GET', '/api/v1/users'),
+
+ createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
+ request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
+}
+
+export interface ScanResult {
+ library_id: number
+ scanned: number
+ added: number
+ updated: number
+ restored: number
+ missing: number
+ unchanged: number
+ duration_ms: number
+ errors?: string[]
+}
diff --git a/web/src/lib/components/Libraries.svelte b/web/src/lib/components/Libraries.svelte
new file mode 100644
index 0000000..959254f
--- /dev/null
+++ b/web/src/lib/components/Libraries.svelte
@@ -0,0 +1,208 @@
+
+
+{#if error}{error}
{/if}
+{#if notice}{notice}
{/if}
+
+{#if $user?.is_admin}
+
+ (showForm = !showForm)}>
+ {showForm ? 'Cancel' : 'Add library'}
+
+
+{/if}
+
+{#if showForm && $user?.is_admin}
+
+{/if}
+
+{#if shown.length === 0}
+
+
No libraries yet
+
+ {#if $user?.is_admin}
+ Add a library and point it at a folder on the server, then run a scan.
+ {:else}
+ You do not have access to any libraries yet. Ask an administrator to grant one.
+ {/if}
+
+
+{:else}
+
+ {#each shown as lib (lib.id)}
+
+
go({ name: 'library', id: lib.id })}>
+ {lib.kind}
+ {lib.name}
+ {lib.root_path}
+
+ {#if $user?.is_admin}
+
+ scan(lib.id)} disabled={busy}>Scan
+ remove(lib.id, lib.name)} disabled={busy}>
+ Delete
+
+
+ {/if}
+
+ {/each}
+
+{/if}
+
+
diff --git a/web/src/lib/components/LibraryView.svelte b/web/src/lib/components/LibraryView.svelte
new file mode 100644
index 0000000..56a5436
--- /dev/null
+++ b/web/src/lib/components/LibraryView.svelte
@@ -0,0 +1,114 @@
+
+
+{#if loading}
+
+{:else if error}
+ {error}
+{:else if items.length === 0}
+
+
Nothing here yet
+
Run a scan on this library to index the files on disk.
+
+{:else}
+
+ {#each items as item (item.id)}
+
go({ name: 'reader', itemId: item.id, title: item.title })}>
+
+
(e.currentTarget as HTMLImageElement).remove()} />
+
{item.title.slice(0, 1).toUpperCase()}
+
+ {item.title}
+
+ {/each}
+
+{/if}
+
+
diff --git a/web/src/lib/components/Login.svelte b/web/src/lib/components/Login.svelte
new file mode 100644
index 0000000..b7a1485
--- /dev/null
+++ b/web/src/lib/components/Login.svelte
@@ -0,0 +1,107 @@
+
+
+
+
+
diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte
new file mode 100644
index 0000000..334f9ce
--- /dev/null
+++ b/web/src/lib/components/Reader.svelte
@@ -0,0 +1,164 @@
+
+
+{#if error}
+ {error}
+{:else if item && item.kind !== 'ebooks'}
+
+{:else}
+
+
+ {#if loading}
+
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte
new file mode 100644
index 0000000..59fd378
--- /dev/null
+++ b/web/src/lib/components/Shell.svelte
@@ -0,0 +1,201 @@
+
+
+
+
+
+ V
+ Vellum
+
+
+
+ selectKind(null)}>
+ All libraries
+ {$libraries.length}
+
+
+ {#each kinds as kind (kind.Kind)}
+ selectKind(kind.Kind)}>
+ {kind.Name}
+ {countsFor(kind.Kind)}
+
+ {/each}
+
+
+ {#if $user?.is_admin}
+ Admin
+
+ go({ name: 'users' })}>
+ Users
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if $view.name === 'libraries'}
+
+ {:else if $view.name === 'library'}
+
+ {:else if $view.name === 'reader'}
+
+ {:else if $view.name === 'users'}
+
+ {/if}
+
+
+
+
+
diff --git a/web/src/lib/components/Users.svelte b/web/src/lib/components/Users.svelte
new file mode 100644
index 0000000..9cb452a
--- /dev/null
+++ b/web/src/lib/components/Users.svelte
@@ -0,0 +1,139 @@
+
+
+{#if error}{error}
{/if}
+
+
+
+ {#if loading}
+
+ {:else}
+ {#each users as u (u.id)}
+
+
+ {u.display_name || u.username}
+ {u.username}
+
+ {#if u.is_admin}
admin {/if}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
diff --git a/web/src/lib/nav.ts b/web/src/lib/nav.ts
new file mode 100644
index 0000000..8e2b418
--- /dev/null
+++ b/web/src/lib/nav.ts
@@ -0,0 +1,15 @@
+import { writable } from 'svelte/store'
+
+// The app is small enough that a typed discriminated union beats a router.
+export type View =
+ | { name: 'libraries' }
+ | { name: 'library'; id: number }
+ | { name: 'reader'; itemId: number; title: string }
+ | { name: 'users' }
+ | { name: 'settings' }
+
+export const view = writable({ name: 'libraries' })
+
+export function go(next: View) {
+ view.set(next)
+}
diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts
new file mode 100644
index 0000000..f215bcd
--- /dev/null
+++ b/web/src/lib/session.ts
@@ -0,0 +1,60 @@
+import { writable } from 'svelte/store'
+import { api, ApiError, type Library, type MediaType, type User } from './api'
+
+// Session state. Classic stores rather than runes-in-a-module: this is a plain
+// .ts file, so it needs no special module handling and every component can use
+// $store auto-subscription.
+
+export const ready = writable(false)
+export const user = writable(null)
+export const visibleLibraries = writable([])
+export const libraries = writable([])
+export const mediaTypes = writable([])
+export const loadingLibraries = writable(false)
+export const bootError = writable(null)
+
+export async function bootstrap() {
+ try {
+ const [me, types] = await Promise.all([api.me(), api.mediaTypes()])
+ user.set(me.user)
+ visibleLibraries.set(me.visible_libraries)
+ mediaTypes.set(types.media_types)
+ await refreshLibraries()
+ } catch (err) {
+ if (!(err instanceof ApiError && err.status === 401)) {
+ bootError.set(err instanceof Error ? err.message : 'failed to reach the server')
+ }
+ user.set(null)
+ } finally {
+ ready.set(true)
+ }
+}
+
+export async function refreshLibraries() {
+ loadingLibraries.set(true)
+ try {
+ const res = await api.libraries()
+ libraries.set(res.libraries)
+ } finally {
+ loadingLibraries.set(false)
+ }
+}
+
+export async function login(username: string, password: string) {
+ const res = await api.login(username, password)
+ user.set(res.user)
+ bootError.set(null)
+ const me = await api.me()
+ visibleLibraries.set(me.visible_libraries)
+ await refreshLibraries()
+}
+
+export async function logout() {
+ try {
+ await api.logout()
+ } finally {
+ user.set(null)
+ libraries.set([])
+ visibleLibraries.set([])
+ }
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..fcf8922
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,7 @@
+import { mount } from 'svelte'
+import './app.css'
+import App from './App.svelte'
+
+const app = mount(App, { target: document.getElementById('app')! })
+
+export default app
diff --git a/web/svelte.config.js b/web/svelte.config.js
new file mode 100644
index 0000000..8abe436
--- /dev/null
+++ b/web/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+export default {
+ preprocess: vitePreprocess(),
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..72a8e44
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["svelte", "vite/client"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..115f7b3
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+// The bundle is embedded into the Go binary and served from /, so all asset
+// paths must be relative (base: './').
+export default defineConfig({
+ plugins: [svelte()],
+ base: './',
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ target: 'es2020',
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:7788',
+ '/healthz': 'http://localhost:7788',
+ },
+ },
+})